File size: 53,894 Bytes
347d1a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
"""
Edge refinement using Sobel gradient filtering.

This module implements v1's core innovation: replacing contour-based width
measurement with gradient-based edge detection for improved accuracy.

Functions:
- extract_ring_zone_roi: Extract ROI around ring zone
- apply_sobel_filters: Bidirectional Sobel filtering
- detect_edges_per_row: Find left/right edges in each cross-section
- refine_edge_subpixel: Sub-pixel edge localization (Phase 3)
- measure_width_from_edges: Compute width from edge positions
- compute_edge_quality_score: Assess edge detection quality (Phase 3)
- should_use_sobel_measurement: Auto fallback logic (Phase 3)
- refine_edges_sobel: Main entry point for edge refinement
"""

import cv2
import numpy as np
import logging
from typing import Dict, Any, Optional, Tuple, List

from src.edge_refinement_constants import (
    # Sobel Filter
    DEFAULT_KERNEL_SIZE,
    VALID_KERNEL_SIZES,
    # Edge Detection
    DEFAULT_GRADIENT_THRESHOLD,
    MIN_FINGER_WIDTH_CM,
    MAX_FINGER_WIDTH_CM,
    WIDTH_TOLERANCE_FACTOR,
    # Sub-Pixel Refinement
    MAX_SUBPIXEL_OFFSET,
    MIN_PARABOLA_DENOMINATOR,
    # Outlier Filtering
    MAD_OUTLIER_THRESHOLD,
    # Edge Quality Scoring
    GRADIENT_STRENGTH_NORMALIZER,
    SMOOTHNESS_VARIANCE_NORMALIZER,
    QUALITY_WEIGHT_GRADIENT,
    QUALITY_WEIGHT_CONSISTENCY,
    QUALITY_WEIGHT_SMOOTHNESS,
    QUALITY_WEIGHT_SYMMETRY,
    # Auto Fallback Decision
    MIN_QUALITY_SCORE_THRESHOLD,
    MIN_CONSISTENCY_THRESHOLD,
    MIN_REALISTIC_WIDTH_CM,
    MAX_REALISTIC_WIDTH_CM,
    MAX_CONTOUR_DIFFERENCE_PCT,
)

# Configure logging
logger = logging.getLogger(__name__)


# =============================================================================
# Helper Functions (extracted from nested scope)
# =============================================================================

def _get_axis_x_at_row(row_y: float, axis_center: Optional[np.ndarray],
                       axis_direction: Optional[np.ndarray], width: int) -> float:
    """
    Get axis x-coordinate at given row y-coordinate.

    Args:
        row_y: Row y-coordinate
        axis_center: Axis center point (x, y)
        axis_direction: Axis direction vector (dx, dy)
        width: Image width (for fallback)

    Returns:
        X-coordinate of axis at given row
    """
    if axis_center is None or axis_direction is None:
        return width / 2  # Fallback to center

    if abs(axis_direction[1]) < 1e-6:
        # Nearly horizontal axis
        return axis_center[0]
    else:
        # Parametric line: P = axis_center + t * axis_direction
        t = (row_y - axis_center[1]) / axis_direction[1]
        return axis_center[0] + t * axis_direction[0]


def _find_edges_from_axis(
    row_gradient: np.ndarray,
    row_y: float,
    axis_x: float,
    threshold: float,
    min_width_px: Optional[float],
    max_width_px: Optional[float],
    row_mask: Optional[np.ndarray] = None,
    row_gradient_left_to_right: Optional[np.ndarray] = None,
    row_gradient_right_to_left: Optional[np.ndarray] = None,
) -> Optional[Tuple[float, float, float, float]]:
    """
    Find left and right edges by expanding from axis position.

    Strategy:
    - MASK-CONSTRAINED MODE (when row_mask provided):
      1. Find leftmost/rightmost mask pixels (finger boundaries)
      2. Search for strongest gradient within ±10px of mask boundaries
      3. Combines anatomical accuracy (mask) with sub-pixel precision (gradient)

    - AXIS-EXPANSION MODE (when row_mask is None):
      1. Start at axis x-coordinate (INSIDE the finger)
      2. Search LEFT/RIGHT from axis for closest salient edge
      3. Validate width is within realistic range

    Args:
        row_gradient: Gradient magnitude for this row
        row_y: Row y-coordinate
        axis_x: Axis x-coordinate at this row
        threshold: Gradient threshold for valid edge
        min_width_px: Minimum valid width in pixels (None to skip)
        max_width_px: Maximum valid width in pixels (None to skip)
        row_mask: Optional mask row (True = finger pixel) for constrained search
        row_gradient_left_to_right: Optional directional gradient map for right edge search
        row_gradient_right_to_left: Optional directional gradient map for left edge search

    Returns:
        Tuple of (left_x, right_x, left_strength, right_strength) or None if invalid
    """
    if axis_x < 0 or axis_x >= len(row_gradient):
        return None

    # Direction-aware gradient maps (preferred when available):
    # - left boundary should come from right-to-left transition
    # - right boundary should come from left-to-right transition
    left_search_gradient = row_gradient_right_to_left if row_gradient_right_to_left is not None else row_gradient
    right_search_gradient = row_gradient_left_to_right if row_gradient_left_to_right is not None else row_gradient

    # MASK-CONSTRAINED MODE (preferred when available)
    if row_mask is not None and np.any(row_mask):
        # Strategy: Search FROM axis OUTWARD, constrained by mask
        # This avoids picking background edges while using gradient precision

        mask_indices = np.where(row_mask)[0]
        if len(mask_indices) < 2:
            return None  # Mask too small

        left_mask_boundary = mask_indices[0]
        right_mask_boundary = mask_indices[-1]

        # Search LEFT from axis, stopping at mask boundary
        left_edge_x = None
        left_strength = 0

        # Start from axis, go left until we reach left mask boundary
        search_start = max(left_mask_boundary, int(axis_x))
        for x in range(search_start, left_mask_boundary - 1, -1):
            if x < 0 or x >= len(row_gradient):
                continue
            if left_search_gradient[x] > threshold:
                # Found a strong edge - update if stronger than previous
                if left_search_gradient[x] > left_strength:
                    left_edge_x = x
                    left_strength = left_search_gradient[x]

        # If no edge found with full threshold, try with relaxed threshold
        if left_edge_x is None:
            relaxed_threshold = threshold * 0.5
            for x in range(search_start, left_mask_boundary - 1, -1):
                if x < 0 or x >= len(row_gradient):
                    continue
                if left_search_gradient[x] > relaxed_threshold:
                    if left_search_gradient[x] > left_strength:
                        left_edge_x = x
                        left_strength = left_search_gradient[x]

        # Search RIGHT from axis, stopping at mask boundary
        right_edge_x = None
        right_strength = 0

        # Start from axis, go right until we reach right mask boundary
        search_start = min(right_mask_boundary, int(axis_x))
        for x in range(search_start, right_mask_boundary + 1):
            if x < 0 or x >= len(row_gradient):
                continue
            if right_search_gradient[x] > threshold:
                # Found a strong edge - update if stronger than previous
                if right_search_gradient[x] > right_strength:
                    right_edge_x = x
                    right_strength = right_search_gradient[x]

        # If no edge found with full threshold, try with relaxed threshold
        if right_edge_x is None:
            relaxed_threshold = threshold * 0.5
            for x in range(search_start, right_mask_boundary + 1):
                if x < 0 or x >= len(row_gradient):
                    continue
                if right_search_gradient[x] > relaxed_threshold:
                    if right_search_gradient[x] > right_strength:
                        right_edge_x = x
                        right_strength = right_search_gradient[x]

        if left_edge_x is None or right_edge_x is None:
            return None  # No valid edges found

    else:
        # AXIS-EXPANSION MODE (fallback when no mask)
        # Search LEFT from axis (go leftward)
        left_edge_x = None
        left_strength = 0
        for x in range(int(axis_x), -1, -1):
            if left_search_gradient[x] > threshold:
                # Found a salient edge - this is our left boundary
                left_edge_x = x
                left_strength = left_search_gradient[x]
                break

        # Search RIGHT from axis (go rightward)
        right_edge_x = None
        right_strength = 0
        for x in range(int(axis_x), len(row_gradient)):
            if right_search_gradient[x] > threshold:
                # Found a salient edge - this is our right boundary
                right_edge_x = x
                right_strength = right_search_gradient[x]
                break

        if left_edge_x is None or right_edge_x is None:
            return None

    # Validate width is within realistic finger range
    width = right_edge_x - left_edge_x
    if min_width_px is not None and max_width_px is not None:
        if width < min_width_px or width > max_width_px:
            return None  # Width out of realistic range

    return (left_edge_x, right_edge_x, left_strength, right_strength)


# =============================================================================
# Main Functions
# =============================================================================

def extract_ring_zone_roi(
    image: np.ndarray,
    axis_data: Dict[str, Any],
    zone_data: Dict[str, Any],
    rotate_align: bool = False
) -> Dict[str, Any]:
    """
    Extract ROI around ring zone.

    The ROI is sized from the zone length (|DIP - PIP|): 1.5x wide, 0.5x tall,
    centered on the ring zone center. This scales naturally with camera
    distance since it's derived from anatomical landmarks.

    Args:
        image: Input BGR image
        axis_data: Output from estimate_finger_axis()
        zone_data: Output from localize_ring_zone()
        rotate_align: If True, rotate ROI so finger axis is vertical

    Returns:
        Dictionary containing:
        - roi_image: Extracted ROI (grayscale)
        - roi_mask: Full ROI mask (all 255)
        - roi_bounds: (x_min, y_min, x_max, y_max) in original image
        - transform_matrix: 3x3 matrix to map ROI coords -> original coords
        - inverse_transform: 3x3 matrix to map original -> ROI coords
        - rotation_angle: Rotation angle applied (degrees)
        - roi_width: ROI width in pixels
        - roi_height: ROI height in pixels
    """
    h, w = image.shape[:2]

    # ROI centered on ring zone center, sized from |DIP - PIP| distance:
    #   height = 0.5x zone length (along finger axis)
    #   width  = 1.5x zone length (perpendicular, wider to capture full finger edges)
    zone_length = zone_data["length"]
    center = zone_data["center_point"]
    direction = axis_data["direction"]
    half_height = zone_length * 0.25 # 0.5x / 2
    half_width = zone_length * 0.6  # 1.5x / 2

    x_min = int(np.clip(center[0] - half_width, 0, w - 1))
    x_max = int(np.clip(center[0] + half_width, 0, w - 1))
    y_min = int(np.clip(center[1] - half_height, 0, h - 1))
    y_max = int(np.clip(center[1] + half_height, 0, h - 1))

    roi_width = x_max - x_min
    roi_height = y_max - y_min

    if roi_width < 10 or roi_height < 10:
        raise ValueError(f"ROI too small: {roi_width}x{roi_height}")

    # Extract ROI
    roi_bgr = image[y_min:y_max, x_min:x_max].copy()

    # Convert to grayscale for edge detection
    roi_gray = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2GRAY)

    # Full ROI mask — the ROI rectangle itself is the search constraint
    roi_mask = np.ones((roi_height, roi_width), dtype=np.uint8) * 255

    # Create transform matrix (ROI coords -> original coords)
    # Simple translation for non-rotated case
    transform = np.eye(3, dtype=np.float32)
    transform[0, 2] = x_min  # Translation in x
    transform[1, 2] = y_min  # Translation in y

    inverse_transform = np.linalg.inv(transform)

    rotation_angle = 0.0

    # Optional rotation alignment
    if rotate_align:
        # Calculate rotation angle to make finger vertical
        # Finger direction -> make it point upward (0, -1)
        # Current direction is (dx, dy), want to rotate to (0, -1)
        rotation_angle = np.degrees(np.arctan2(-direction[0], direction[1]))

        # Get rotation matrix
        roi_center = (roi_width / 2.0, roi_height / 2.0)
        rotation_matrix = cv2.getRotationMatrix2D(roi_center, rotation_angle, 1.0)

        # Rotate ROI
        roi_gray = cv2.warpAffine(
            roi_gray, rotation_matrix, (roi_width, roi_height),
            flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE
        )

        # Update transform matrices
        # Rotation matrix is 2x3, convert to 3x3 for composition
        rotation_matrix_3x3 = np.eye(3, dtype=np.float32)
        rotation_matrix_3x3[:2, :] = rotation_matrix

        # Compose: translate then rotate
        transform = np.dot(rotation_matrix_3x3, transform)
        inverse_transform = np.linalg.inv(transform)

    # Convert axis center point and direction to ROI coordinates
    axis_center = axis_data.get("center", center)
    roi_offset = np.array([x_min, y_min], dtype=np.float32)
    axis_center_in_roi = axis_center - roi_offset

    # Direction vector stays the same (it's not affected by translation)
    axis_direction_in_roi = direction.copy()

    zone_start = zone_data["start_point"]
    zone_end = zone_data["end_point"]

    return {
        "roi_image": roi_gray,
        "roi_mask": roi_mask,
        "roi_bgr": roi_bgr,  # Keep BGR for debug visualization
        "roi_bounds": (x_min, y_min, x_max, y_max),
        "transform_matrix": transform,
        "inverse_transform": inverse_transform,
        "rotation_angle": rotation_angle,
        "roi_width": roi_width,
        "roi_height": roi_height,
        "zone_start_in_roi": zone_start - roi_offset,
        "zone_end_in_roi": zone_end - roi_offset,
        "axis_center_in_roi": axis_center_in_roi,
        "axis_direction_in_roi": axis_direction_in_roi,
    }


def apply_sobel_filters(
    roi_image: np.ndarray,
    kernel_size: int = DEFAULT_KERNEL_SIZE,
    axis_direction: str = "auto"
) -> Dict[str, Any]:
    """
    Apply bidirectional Sobel filters to detect edges.

    For vertical finger (axis_direction="vertical"):
    - Use horizontal Sobel kernels (detect left/right edges)

    For horizontal finger (axis_direction="horizontal"):
    - Use vertical Sobel kernels (detect top/bottom edges)

    Auto mode detects orientation from ROI aspect ratio.

    Args:
        roi_image: Grayscale ROI image
        kernel_size: Sobel kernel size (3, 5, or 7)
        axis_direction: Finger axis direction ("auto", "vertical", "horizontal")

    Returns:
        Dictionary containing:
        - gradient_x: Horizontal gradient (Sobel X)
        - gradient_y: Vertical gradient (Sobel Y)
        - gradient_left_to_right: Positive X-gradient map (right-half gated in horizontal mode)
        - gradient_right_to_left: Negative X-gradient map (left-half gated in horizontal mode)
        - gradient_magnitude: Combined gradient magnitude
        - gradient_direction: Edge orientation (radians)
        - kernel_size: Kernel size used
        - filter_orientation: "horizontal" or "vertical"
    """
    if kernel_size not in VALID_KERNEL_SIZES:
        raise ValueError(f"Invalid kernel_size: {kernel_size}. Use {VALID_KERNEL_SIZES}")

    h, w = roi_image.shape

    # Determine filter orientation
    if axis_direction == "auto":
        # After rotation normalization, finger is always vertical (upright)
        # Finger runs vertically → detect left/right edges → use horizontal filter
        #
        # NOTE: ROI aspect ratio is NOT reliable after rotation normalization!
        # The ROI may be wider than tall even when finger is vertical.
        # Always use horizontal filter orientation for upright hands.
        filter_orientation = "horizontal"  # Detect left/right edges for vertical finger
    elif axis_direction == "vertical":
        filter_orientation = "horizontal"
    elif axis_direction == "horizontal":
        filter_orientation = "vertical"
    else:
        raise ValueError(f"Invalid axis_direction: {axis_direction}")

    # Apply Sobel filters
    # Sobel X detects vertical edges (left/right boundaries)
    # Sobel Y detects horizontal edges (top/bottom boundaries)

    # Use cv2.Sobel for standard implementation
    grad_x = cv2.Sobel(roi_image, cv2.CV_64F, 1, 0, ksize=kernel_size)
    grad_y = cv2.Sobel(roi_image, cv2.CV_64F, 0, 1, ksize=kernel_size)

    # Directional Sobel responses along X:
    # - left_to_right: rising intensity while moving left -> right
    # - right_to_left: falling intensity while moving left -> right
    gradient_left_to_right = np.maximum(grad_x, 0.0)
    gradient_right_to_left = np.maximum(-grad_x, 0.0)

    # Spatial gating to reduce nearby non-target finger interference:
    # - left_to_right only on ROI right half
    # - right_to_left only on ROI left half
    roi_split_x = w // 2
    if filter_orientation == "horizontal":
        gradient_left_to_right[:, :roi_split_x] = 0.0
        gradient_right_to_left[:, roi_split_x:] = 0.0
        gradient_magnitude = np.sqrt(gradient_left_to_right**2 + gradient_right_to_left**2)
    else:
        # Vertical mode fallback keeps the original behavior.
        gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2)

    # Calculate gradient direction (angle)
    gradient_direction = np.arctan2(grad_y, grad_x)

    # Normalize gradients to 0-255 for visualization
    grad_x_normalized = np.clip(np.abs(grad_x), 0, 255).astype(np.uint8)
    grad_y_normalized = np.clip(np.abs(grad_y), 0, 255).astype(np.uint8)
    grad_mag_normalized = np.clip(gradient_magnitude, 0, 255).astype(np.uint8)
    grad_l2r_normalized = np.clip(gradient_left_to_right, 0, 255).astype(np.uint8)
    grad_r2l_normalized = np.clip(gradient_right_to_left, 0, 255).astype(np.uint8)

    return {
        "gradient_x": grad_x,
        "gradient_y": grad_y,
        "gradient_left_to_right": gradient_left_to_right,
        "gradient_right_to_left": gradient_right_to_left,
        "gradient_magnitude": gradient_magnitude,
        "gradient_direction": gradient_direction,
        "gradient_x_normalized": grad_x_normalized,
        "gradient_y_normalized": grad_y_normalized,
        "gradient_left_to_right_normalized": grad_l2r_normalized,
        "gradient_right_to_left_normalized": grad_r2l_normalized,
        "gradient_mag_normalized": grad_mag_normalized,
        "kernel_size": kernel_size,
        "filter_orientation": filter_orientation,
        "roi_split_x": roi_split_x,
    }


def detect_edges_per_row(
    gradient_data: Dict[str, Any],
    roi_data: Dict[str, Any],
    threshold: float = DEFAULT_GRADIENT_THRESHOLD,
    expected_width_px: Optional[float] = None,
    scale_px_per_cm: Optional[float] = None
) -> Dict[str, Any]:
    """
    Detect left and right finger edges for each row (cross-section).

    Uses mask-constrained mode when roi_mask is available:
    1. Find leftmost/rightmost mask pixels (anatomical finger boundaries)
    2. Search for gradient peaks within ±10px of mask boundaries
    3. Combines anatomical accuracy with sub-pixel gradient precision

    Falls back to axis-expansion mode when no mask:
    1. Start at finger axis (guaranteed inside finger)
    2. Expand left/right to find nearest salient edges
    3. Validate width is within realistic range

    Args:
        gradient_data: Output from apply_sobel_filters()
        roi_data: Output from extract_ring_zone_roi()
        threshold: Minimum gradient magnitude for valid edge
        expected_width_px: Expected finger width from contour (optional)
        scale_px_per_cm: Scale factor for width validation (optional)

    Returns:
        Dictionary containing:
        - left_edges: Array of left edge x-coordinates (one per row)
        - right_edges: Array of right edge x-coordinates (one per row)
        - edge_strengths_left: Gradient magnitude at left edges
        - edge_strengths_right: Gradient magnitude at right edges
        - valid_rows: Boolean mask of rows with successful detection
        - num_valid_rows: Count of successful detections
        - mode_used: "mask_constrained" or "axis_expansion"
    """
    gradient_magnitude = gradient_data["gradient_magnitude"]
    gradient_left_to_right = gradient_data.get("gradient_left_to_right")
    gradient_right_to_left = gradient_data.get("gradient_right_to_left")
    filter_orientation = gradient_data["filter_orientation"]

    h, w = gradient_magnitude.shape

    # Calculate realistic finger width range in pixels
    min_width_px = None
    max_width_px = None
    if scale_px_per_cm is not None:
        min_width_px = MIN_FINGER_WIDTH_CM * scale_px_per_cm
        max_width_px = MAX_FINGER_WIDTH_CM * scale_px_per_cm
        logger.debug(f"Width constraint: {min_width_px:.1f}-{max_width_px:.1f}px ({MIN_FINGER_WIDTH_CM}-{MAX_FINGER_WIDTH_CM}cm)")
    elif expected_width_px is not None:
        # Use expected width with tolerance
        min_width_px = expected_width_px * (1 - WIDTH_TOLERANCE_FACTOR)
        max_width_px = expected_width_px * (1 + WIDTH_TOLERANCE_FACTOR)
        logger.debug(f"Width constraint: {min_width_px:.1f}-{max_width_px:.1f}px (±{WIDTH_TOLERANCE_FACTOR*100}% of expected)")
    else:
        logger.debug("No width constraint (scale and expected width both None)")

    # Get axis information - this is our strong anchor point (INSIDE the finger)
    axis_center = roi_data.get("axis_center_in_roi")
    axis_direction = roi_data.get("axis_direction_in_roi")
    zone_start = roi_data.get("zone_start_in_roi")
    zone_end = roi_data.get("zone_end_in_roi")

    # Get finger mask for constrained edge detection (if available)
    roi_mask = roi_data.get("roi_mask")
    mode_used = "mask_constrained" if roi_mask is not None else "axis_expansion"

    if roi_mask is not None:
        logger.debug(f"Using MASK-CONSTRAINED edge detection (mask shape: {roi_mask.shape})")
    else:
        logger.debug("Using AXIS-EXPANSION edge detection (no mask available)")

    # For horizontal filter orientation (detecting left/right edges)
    # Process each row to find left and right edges
    if filter_orientation == "horizontal":
        num_rows = h
        left_edges = np.full(num_rows, -1.0, dtype=np.float32)
        right_edges = np.full(num_rows, -1.0, dtype=np.float32)
        edge_strengths_left = np.zeros(num_rows, dtype=np.float32)
        edge_strengths_right = np.zeros(num_rows, dtype=np.float32)
        valid_rows = np.zeros(num_rows, dtype=bool)

        for row in range(num_rows):
            # Get axis position (our anchor point INSIDE the finger)
            axis_x = _get_axis_x_at_row(row, axis_center, axis_direction, w)

            # Get gradient for this row
            row_gradient = gradient_magnitude[row, :]
            row_gradient_l2r = gradient_left_to_right[row, :] if gradient_left_to_right is not None else None
            row_gradient_r2l = gradient_right_to_left[row, :] if gradient_right_to_left is not None else None

            # Get mask for this row (if available)
            row_mask = roi_mask[row, :] if roi_mask is not None else None

            # Find edges using mask-constrained or axis-expansion method
            result = _find_edges_from_axis(row_gradient, row, axis_x, threshold,
                                          min_width_px, max_width_px, row_mask,
                                          row_gradient_left_to_right=row_gradient_l2r,
                                          row_gradient_right_to_left=row_gradient_r2l)

            if result is None:
                continue  # No valid edges found

            left_edge_x, right_edge_x, left_strength, right_strength = result

            # Mark as valid
            left_edges[row] = float(left_edge_x)
            right_edges[row] = float(right_edge_x)
            edge_strengths_left[row] = left_strength
            edge_strengths_right[row] = right_strength
            valid_rows[row] = True

    else:
        # Vertical filter orientation (detecting top/bottom edges)
        # Process each column
        num_cols = w
        left_edges = np.full(num_cols, -1.0, dtype=np.float32)
        right_edges = np.full(num_cols, -1.0, dtype=np.float32)
        edge_strengths_left = np.zeros(num_cols, dtype=np.float32)
        edge_strengths_right = np.zeros(num_cols, dtype=np.float32)
        valid_rows = np.zeros(num_cols, dtype=bool)

        roi_center_y = h / 2.0

        for col in range(num_cols):
            col_gradient = gradient_magnitude[:, col]

            strong_edges = np.where(col_gradient > threshold)[0]

            if len(strong_edges) < 2:
                continue

            top_candidates = strong_edges[strong_edges < roi_center_y]
            bottom_candidates = strong_edges[strong_edges >= roi_center_y]

            if len(top_candidates) == 0 or len(bottom_candidates) == 0:
                continue

            # Select edges closest to center (finger boundaries)
            top_edge_y = top_candidates[-1]  # Bottommost of top candidates
            bottom_edge_y = bottom_candidates[0]  # Topmost of bottom candidates

            top_strength = col_gradient[top_edge_y]
            bottom_strength = col_gradient[bottom_edge_y]

            height = bottom_edge_y - top_edge_y

            if expected_width_px is not None:
                if height < expected_width_px * 0.5 or height > expected_width_px * 1.5:
                    continue

            left_edges[col] = float(top_edge_y)
            right_edges[col] = float(bottom_edge_y)
            edge_strengths_left[col] = top_strength
            edge_strengths_right[col] = bottom_strength
            valid_rows[col] = True

    num_valid = np.sum(valid_rows)

    return {
        "left_edges": left_edges,
        "right_edges": right_edges,
        "edge_strengths_left": edge_strengths_left,
        "edge_strengths_right": edge_strengths_right,
        "valid_rows": valid_rows,
        "num_valid_rows": int(num_valid),
        "filter_orientation": filter_orientation,
        "mode_used": mode_used,  # "mask_constrained" or "axis_expansion"
    }


def refine_edge_subpixel(
    gradient_magnitude: np.ndarray,
    edge_positions: np.ndarray,
    valid_mask: np.ndarray,
    method: str = "parabola"
) -> np.ndarray:
    """
    Refine edge positions to sub-pixel precision.

    Uses parabola fitting on gradient magnitude to find peak position
    with <0.5 pixel accuracy.

    Args:
        gradient_magnitude: 2D gradient magnitude array
        edge_positions: Integer edge positions (one per row/col)
        valid_mask: Boolean mask indicating which positions are valid
        method: Refinement method ("parabola" or "gaussian")

    Returns:
        Refined edge positions (float, sub-pixel precision)
    """
    refined_positions = edge_positions.copy()

    if method == "parabola":
        # Parabola fitting: fit f(x) = ax^2 + bx + c to 3 points
        # Peak at x = -b/(2a)

        for i in range(len(edge_positions)):
            if not valid_mask[i]:
                continue

            edge_pos = int(edge_positions[i])

            # Get gradient magnitude at edge and neighbors
            # Handle edge cases (pun intended)
            if edge_pos <= 0 or edge_pos >= gradient_magnitude.shape[1] - 1:
                continue  # Can't refine at image boundaries

            # For horizontal orientation (row-wise edge detection)
            if len(gradient_magnitude.shape) == 2 and i < gradient_magnitude.shape[0]:
                # Sample gradient at x-1, x, x+1
                x_minus = edge_pos - 1
                x_center = edge_pos
                x_plus = edge_pos + 1

                g_minus = gradient_magnitude[i, x_minus]
                g_center = gradient_magnitude[i, x_center]
                g_plus = gradient_magnitude[i, x_plus]

                # Fit parabola: f(x) = ax^2 + bx + c
                # Using x = -1, 0, 1 for simplicity
                # f(-1) = a - b + c = g_minus
                # f(0) = c = g_center
                # f(1) = a + b + c = g_plus

                c = g_center
                a = (g_plus + g_minus - 2 * c) / 2.0
                b = (g_plus - g_minus) / 2.0

                # Peak at x_peak = -b/(2a)
                if abs(a) > MIN_PARABOLA_DENOMINATOR:  # Avoid division by zero
                    x_peak = -b / (2.0 * a)

                    # Constrain to reasonable range
                    if abs(x_peak) <= MAX_SUBPIXEL_OFFSET:
                        refined_positions[i] = edge_pos + x_peak

    elif method == "gaussian":
        # Gaussian fitting (more complex, not implemented yet)
        # Would fit Gaussian to 5-pixel window
        # For now, fall back to parabola
        return refine_edge_subpixel(gradient_magnitude, edge_positions, valid_mask, method="parabola")

    else:
        raise ValueError(f"Unknown refinement method: {method}")

    return refined_positions


def measure_width_from_edges(
    edge_data: Dict[str, Any],
    roi_data: Dict[str, Any],
    scale_px_per_cm: float,
    gradient_data: Optional[Dict[str, Any]] = None,
    use_subpixel: bool = True
) -> Dict[str, Any]:
    """
    Compute finger width from detected edges.

    Steps:
    1. Apply sub-pixel refinement if gradient data available
    2. Calculate width for each valid row: width_px = right_edge - left_edge
    3. Filter outliers (>3 MAD from median)
    4. Compute statistics (median, mean, std)
    5. Convert width from pixels to cm

    Args:
        edge_data: Output from detect_edges_per_row()
        roi_data: Output from extract_ring_zone_roi()
        scale_px_per_cm: Pixels per cm from card detection
        gradient_data: Optional gradient data for sub-pixel refinement
        use_subpixel: Enable sub-pixel refinement (default True)

    Returns:
        Dictionary containing:
        - widths_px: Array of width measurements (pixels)
        - median_width_px: Median width in pixels
        - median_width_cm: Median width in cm (final measurement)
        - mean_width_px: Mean width in pixels
        - std_width_px: Standard deviation of widths
        - num_samples: Number of valid width measurements
        - outliers_removed: Number of outliers filtered
        - subpixel_refinement_used: Whether sub-pixel refinement was applied
    """
    left_edges = edge_data["left_edges"].copy()
    right_edges = edge_data["right_edges"].copy()
    valid_rows = edge_data["valid_rows"]

    # Apply sub-pixel refinement if available
    subpixel_used = False
    if use_subpixel and gradient_data is not None:
        try:
            gradient_magnitude = gradient_data["gradient_magnitude"]

            # Refine left edges
            left_edges = refine_edge_subpixel(
                gradient_magnitude, left_edges, valid_rows, method="parabola"
            )

            # Refine right edges
            right_edges = refine_edge_subpixel(
                gradient_magnitude, right_edges, valid_rows, method="parabola"
            )

            subpixel_used = True
        except Exception as e:
            logger.warning(f"Sub-pixel refinement failed: {e}, using integer positions")
            # Fall back to integer positions
            left_edges = edge_data["left_edges"]
            right_edges = edge_data["right_edges"]

    # Calculate widths for valid rows
    widths_px = []
    for i in range(len(valid_rows)):
        if valid_rows[i]:
            width = right_edges[i] - left_edges[i]
            if width > 0:
                widths_px.append(width)

    if len(widths_px) == 0:
        raise ValueError("No valid width measurements found")

    widths_px = np.array(widths_px)

    # Filter outliers using median absolute deviation (MAD)
    median = np.median(widths_px)
    mad = np.median(np.abs(widths_px - median))

    # Outliers are >3 MAD from median (more robust than std dev)
    if mad > 0:
        is_outlier = np.abs(widths_px - median) > (MAD_OUTLIER_THRESHOLD * mad)
        widths_filtered = widths_px[~is_outlier]
        outliers_removed = np.sum(is_outlier)
    else:
        widths_filtered = widths_px
        outliers_removed = 0

    if len(widths_filtered) == 0:
        # All measurements were outliers, use original
        widths_filtered = widths_px
        outliers_removed = 0

    # Calculate statistics
    median_width_px = float(np.median(widths_filtered))
    mean_width_px = float(np.mean(widths_filtered))
    std_width_px = float(np.std(widths_filtered))

    # Convert to cm
    median_width_cm = median_width_px / scale_px_per_cm

    # Log measurements
    logger.debug(f"Raw median width: {median_width_px:.2f}px, scale: {scale_px_per_cm:.2f} px/cm → {median_width_cm:.4f}cm")
    logger.debug(f"Width range: {np.min(widths_filtered):.1f}-{np.max(widths_filtered):.1f}px, std: {std_width_px:.1f}px")

    return {
        "widths_px": widths_filtered.tolist(),
        "median_width_px": median_width_px,
        "median_width_cm": median_width_cm,
        "mean_width_px": mean_width_px,
        "std_width_px": std_width_px,
        "num_samples": len(widths_filtered),
        "outliers_removed": int(outliers_removed),
        "subpixel_refinement_used": subpixel_used,
    }


def compute_edge_quality_score(
    gradient_data: Dict[str, Any],
    edge_data: Dict[str, Any],
    width_data: Dict[str, Any]
) -> Dict[str, Any]:
    """
    Assess quality of edge detection for confidence scoring.

    Computes 4 quality metrics:
    1. Gradient strength: Average gradient magnitude at detected edges
    2. Edge consistency: Percentage of rows with valid edge pairs
    3. Edge smoothness: Variance of edge positions along finger
    4. Bilateral symmetry: Correlation between left/right edge quality

    Args:
        gradient_data: Output from apply_sobel_filters()
        edge_data: Output from detect_edges_per_row()
        width_data: Output from measure_width_from_edges()

    Returns:
        Dictionary containing:
        - overall_score: Weighted average (0-1)
        - gradient_strength_score: Gradient strength metric (0-1)
        - consistency_score: Edge detection success rate (0-1)
        - smoothness_score: Edge position smoothness (0-1)
        - symmetry_score: Left/right balance (0-1)
        - metrics: Dict with raw metric values
    """
    gradient_magnitude = gradient_data["gradient_magnitude"]
    left_edges = edge_data["left_edges"]
    right_edges = edge_data["right_edges"]
    valid_rows = edge_data["valid_rows"]
    edge_strengths_left = edge_data["edge_strengths_left"]
    edge_strengths_right = edge_data["edge_strengths_right"]

    # Metric 1: Gradient Strength
    # Average gradient magnitude at detected edges, normalized
    valid_left_strengths = edge_strengths_left[valid_rows]
    valid_right_strengths = edge_strengths_right[valid_rows]

    if len(valid_left_strengths) > 0:
        avg_gradient_strength = (np.mean(valid_left_strengths) + np.mean(valid_right_strengths)) / 2.0
        # Normalize: typical strong edge is 20-50, weak is <10
        gradient_strength_score = min(avg_gradient_strength / GRADIENT_STRENGTH_NORMALIZER, 1.0)
    else:
        avg_gradient_strength = 0.0
        gradient_strength_score = 0.0

    # Metric 2: Edge Consistency
    # Percentage of rows with valid edge pairs
    total_rows = len(valid_rows)
    num_valid = np.sum(valid_rows)
    consistency_score = num_valid / total_rows if total_rows > 0 else 0.0

    # Metric 3: Edge Smoothness
    # Measure variance of edge positions (smoother = better)
    # Lower variance = higher score
    if num_valid > 1:
        # Calculate variance of left and right edges separately
        valid_left = left_edges[valid_rows]
        valid_right = right_edges[valid_rows]

        left_variance = np.var(valid_left)
        right_variance = np.var(valid_right)
        avg_variance = (left_variance + right_variance) / 2.0

        # Normalize: typical finger has variance <100, noisy edges >500
        smoothness_score = np.exp(-avg_variance / SMOOTHNESS_VARIANCE_NORMALIZER)
    else:
        avg_variance = 0.0
        smoothness_score = 0.0

    # Metric 4: Bilateral Symmetry
    # Correlation between left and right edge quality (strength balance)
    if len(valid_left_strengths) > 1:
        # Calculate ratio of average strengths
        avg_left = np.mean(valid_left_strengths)
        avg_right = np.mean(valid_right_strengths)

        if avg_left > 0 and avg_right > 0:
            # Symmetric ratio close to 1.0 is good
            ratio = min(avg_left, avg_right) / max(avg_left, avg_right)
            symmetry_score = ratio  # Already 0-1
        else:
            symmetry_score = 0.0
    else:
        symmetry_score = 0.0

    # Weighted overall score
    overall_score = (
        QUALITY_WEIGHT_GRADIENT * gradient_strength_score +
        QUALITY_WEIGHT_CONSISTENCY * consistency_score +
        QUALITY_WEIGHT_SMOOTHNESS * smoothness_score +
        QUALITY_WEIGHT_SYMMETRY * symmetry_score
    )

    return {
        "overall_score": float(overall_score),
        "gradient_strength_score": float(gradient_strength_score),
        "consistency_score": float(consistency_score),
        "smoothness_score": float(smoothness_score),
        "symmetry_score": float(symmetry_score),
        "metrics": {
            "avg_gradient_strength": float(avg_gradient_strength),
            "edge_consistency_pct": float(consistency_score * 100),
            "avg_variance": float(avg_variance) if num_valid > 1 else 0.0,
            "left_right_strength_ratio": float(symmetry_score),
        }
    }


def should_use_sobel_measurement(
    sobel_result: Dict[str, Any],
    contour_result: Optional[Dict[str, Any]] = None,
    min_quality_score: float = MIN_QUALITY_SCORE_THRESHOLD,
    min_consistency: float = MIN_CONSISTENCY_THRESHOLD,
    max_difference_pct: float = MAX_CONTOUR_DIFFERENCE_PCT
) -> Tuple[bool, str]:
    """
    Decide whether to use Sobel measurement or fall back to contour.

    Decision criteria:
    1. Edge quality score > min_quality_score (default 0.7)
    2. Edge consistency > min_consistency (default 0.5 = 50%)
    3. If contour available: Sobel and contour agree within max_difference_pct

    Args:
        sobel_result: Output from refine_edges_sobel()
        contour_result: Optional output from compute_cross_section_width()
        min_quality_score: Minimum acceptable quality score
        min_consistency: Minimum edge detection success rate
        max_difference_pct: Maximum allowed difference from contour (%)

    Returns:
        Tuple of (should_use_sobel, reason)
    """
    # Check if edge quality data available
    if "edge_quality" not in sobel_result:
        return False, "edge_quality_data_missing"

    edge_quality = sobel_result["edge_quality"]

    # Check 1: Overall quality score
    if edge_quality["overall_score"] < min_quality_score:
        return False, f"quality_score_low_{edge_quality['overall_score']:.2f}"

    # Check 2: Consistency (success rate)
    if edge_quality["consistency_score"] < min_consistency:
        return False, f"consistency_low_{edge_quality['consistency_score']:.2f}"

    # Check 3: Measurement reasonableness
    sobel_width = sobel_result.get("median_width_cm")
    if sobel_width is None or sobel_width <= 0:
        return False, "invalid_measurement"

    # Typical finger width range
    if sobel_width < MIN_REALISTIC_WIDTH_CM or sobel_width > MAX_REALISTIC_WIDTH_CM:
        return False, f"unrealistic_width_{sobel_width:.2f}cm"

    # Check 4: Agreement with contour (if available)
    if contour_result is not None:
        contour_width = contour_result.get("median_width_px")
        sobel_width_px = sobel_result.get("median_width_px")

        if contour_width and sobel_width_px:
            diff_pct = abs(sobel_width_px - contour_width) / contour_width * 100

            if diff_pct > max_difference_pct:
                return False, f"disagrees_with_contour_{diff_pct:.1f}pct"

    # All checks passed
    return True, "quality_acceptable"


def refine_edges_sobel(
    image: np.ndarray,
    axis_data: Dict[str, Any],
    zone_data: Dict[str, Any],
    scale_px_per_cm: float,
    finger_landmarks: Optional[np.ndarray] = None,
    sobel_threshold: float = DEFAULT_GRADIENT_THRESHOLD,
    kernel_size: int = DEFAULT_KERNEL_SIZE,
    rotate_align: bool = False,
    use_subpixel: bool = True,
    expected_width_px: Optional[float] = None,
    debug_dir: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Main entry point for Sobel-based edge refinement.

    Replaces contour-based width measurement with gradient-based edge detection.

    Pipeline:
    1. Extract ROI around ring zone
    2. Apply bidirectional Sobel filters
    3. Detect left/right edges per row
    4. Measure width from edges
    5. Convert to cm and return measurement

    Args:
        image: Input BGR image
        axis_data: Output from estimate_finger_axis()
        zone_data: Output from localize_ring_zone()
        scale_px_per_cm: Pixels per cm from card detection
        finger_landmarks: Optional 4x2 array of finger landmarks for debug
        sobel_threshold: Minimum gradient magnitude for valid edge
        kernel_size: Sobel kernel size (3, 5, or 7)
        rotate_align: Rotate ROI for vertical finger alignment
        use_subpixel: Enable sub-pixel edge localization
        expected_width_px: Expected width for validation (optional)
        debug_dir: Directory to save debug visualizations (None to skip)

    Returns:
        Dictionary containing:
        - median_width_cm: Final measurement in cm
        - median_width_px: Measurement in pixels
        - std_width_px: Standard deviation
        - num_samples: Number of valid measurements
        - edge_detection_success_rate: % of rows with valid edges
        - roi_data: ROI extraction data
        - gradient_data: Sobel filter data
        - edge_data: Edge detection data
        - method: "sobel"
    """
    # Initialize debug observer if debug_dir provided
    if debug_dir:
        from src.debug_observer import DebugObserver, draw_landmark_axis, draw_ring_zone_roi
        from src.debug_observer import draw_roi_extraction, draw_gradient_visualization
        from src.debug_observer import draw_edge_candidates, draw_filtered_edge_candidates
        from src.debug_observer import draw_selected_edges
        from src.debug_observer import draw_width_measurements, draw_outlier_detection
        from src.debug_observer import draw_comprehensive_edge_overlay
        observer = DebugObserver(debug_dir)

    # Stage A: Axis & Zone Visualization
    if debug_dir:
        # A.1: Landmark axis
        observer.draw_and_save("01_landmark_axis", image, draw_landmark_axis, axis_data, finger_landmarks)

        # A.2: Ring zone + ROI bounds (need to extract bounds first)
        # We'll save this after ROI extraction

    # Step 1: Extract ROI
    roi_data = extract_ring_zone_roi(
        image, axis_data, zone_data,
        rotate_align=rotate_align
    )

    logger.debug(f"ROI size: {roi_data['roi_width']}x{roi_data['roi_height']}px")
    logger.debug(f"ROI bounds: {roi_data['roi_bounds']}")

    if debug_dir:
        # A.2: Ring zone + ROI bounds
        roi_bounds = roi_data["roi_bounds"]
        observer.draw_and_save("02_ring_zone_roi", image, draw_ring_zone_roi, zone_data, roi_bounds)

        # A.3: ROI extraction
        observer.draw_and_save("03_roi_extraction", roi_data["roi_image"], draw_roi_extraction, roi_data.get("roi_mask"))

    # Step 2: Apply Sobel filters
    gradient_data = apply_sobel_filters(
        roi_data["roi_image"],
        kernel_size=kernel_size,
        axis_direction="auto"
    )

    if debug_dir:
        # Stage B: Sobel Filtering
        # B.1: Left-to-right gradient
        grad_left = draw_gradient_visualization(gradient_data["gradient_left_to_right"], cv2.COLORMAP_JET)
        observer.save_stage("04_sobel_left_to_right", grad_left)

        # B.2: Right-to-left gradient
        grad_right = draw_gradient_visualization(gradient_data["gradient_right_to_left"], cv2.COLORMAP_JET)
        observer.save_stage("05_sobel_right_to_left", grad_right)

        # B.3: Gradient magnitude
        grad_mag = draw_gradient_visualization(gradient_data["gradient_magnitude"], cv2.COLORMAP_HOT)
        observer.save_stage("06_gradient_magnitude", grad_mag)

    # Step 3: Detect edges per row
    edge_data = detect_edges_per_row(
        gradient_data, roi_data,
        threshold=sobel_threshold,
        expected_width_px=expected_width_px,
        scale_px_per_cm=scale_px_per_cm
    )

    logger.debug(f"Valid rows: {edge_data['num_valid_rows']}/{len(edge_data['valid_rows'])} ({edge_data['num_valid_rows']/len(edge_data['valid_rows'])*100:.1f}%)")
    if edge_data['num_valid_rows'] > 0:
        valid_left = edge_data['left_edges'][edge_data['valid_rows']]
        valid_right = edge_data['right_edges'][edge_data['valid_rows']]
        logger.debug(f"Left edges range: {np.min(valid_left):.1f}-{np.max(valid_left):.1f}px")
        logger.debug(f"Right edges range: {np.min(valid_right):.1f}-{np.max(valid_right):.1f}px")
        widths = valid_right - valid_left
        logger.debug(f"Raw widths range: {np.min(widths):.1f}-{np.max(widths):.1f}px, median: {np.median(widths):.1f}px")

    if debug_dir:
        # B.4a: All edge candidates (raw threshold, shows noise)
        observer.draw_and_save("07a_all_candidates", roi_data["roi_image"],
                             draw_edge_candidates, gradient_data["gradient_magnitude"], sobel_threshold)

        # B.4b: Filtered edge candidates (spatially-filtered, what algorithm uses)
        observer.draw_and_save("07b_filtered_candidates", roi_data["roi_image"],
                             draw_filtered_edge_candidates,
                             gradient_data["gradient_magnitude"],
                             sobel_threshold,
                             roi_data.get("roi_mask"),
                             roi_data["axis_center_in_roi"],
                             roi_data["axis_direction_in_roi"])

        # B.5: Selected edges (final detected edges)
        observer.draw_and_save("09_selected_edges", roi_data["roi_image"], draw_selected_edges, edge_data)

    # Step 4: Measure width from edges (with sub-pixel refinement)
    width_data = measure_width_from_edges(
        edge_data, roi_data, scale_px_per_cm,
        gradient_data=gradient_data,
        use_subpixel=use_subpixel
    )

    if debug_dir:
        # Stage C: Measurement
        # C.1: Sub-pixel refinement (use selected edges for now)
        observer.draw_and_save("10_subpixel_refinement", roi_data["roi_image"], draw_selected_edges, edge_data)

        # C.2: Width measurements
        observer.draw_and_save("11_width_measurements", roi_data["roi_image"],
                             draw_width_measurements, edge_data, width_data)

        # C.3: Width distribution (histogram - requires matplotlib)
        try:
            _save_width_distribution(width_data, debug_dir)
        except:
            pass  # Skip if matplotlib not available

        # C.4: Outlier detection
        observer.draw_and_save("13_outlier_detection", roi_data["roi_image"],
                             draw_outlier_detection, edge_data, width_data)

        # C.5: Comprehensive edge overlay on full image
        observer.draw_and_save("14_comprehensive_overlay", image,
                             draw_comprehensive_edge_overlay,
                             edge_data, roi_data["roi_bounds"], axis_data, zone_data,
                             width_data, scale_px_per_cm)

    # Step 5: Compute edge quality score
    edge_quality = compute_edge_quality_score(
        gradient_data, edge_data, width_data
    )

    # Calculate success rate
    total_rows = len(edge_data["valid_rows"])
    success_rate = edge_data["num_valid_rows"] / total_rows if total_rows > 0 else 0.0

    # Combine results
    return {
        "median_width_cm": width_data["median_width_cm"],
        "median_width_px": width_data["median_width_px"],
        "mean_width_px": width_data["mean_width_px"],
        "std_width_px": width_data["std_width_px"],
        "num_samples": width_data["num_samples"],
        "outliers_removed": width_data["outliers_removed"],
        "subpixel_refinement_used": width_data["subpixel_refinement_used"],
        "edge_detection_success_rate": success_rate,
        "edge_quality": edge_quality,
        "roi_data": roi_data,
        "gradient_data": gradient_data,
        "edge_data": edge_data,
        "width_data": width_data,
        "method": "sobel",
    }


def _save_width_distribution(width_data: Dict[str, Any], debug_dir: str) -> None:
    """Helper to save width distribution histogram."""
    try:
        import matplotlib
        matplotlib.use('Agg')
        import matplotlib.pyplot as plt
        import os
    except ImportError:
        return

    widths_px = width_data.get("widths_px", [])
    if len(widths_px) == 0:
        return

    median_width_px = width_data["median_width_px"]
    mean_width_px = width_data["mean_width_px"]

    # Create histogram
    fig, ax = plt.subplots(figsize=(10, 6))
    ax.hist(widths_px, bins=30, color='skyblue', edgecolor='black', alpha=0.7)
    ax.axvline(median_width_px, color='red', linestyle='--', linewidth=2, label=f'Median: {median_width_px:.1f} px')
    ax.axvline(mean_width_px, color='orange', linestyle='--', linewidth=2, label=f'Mean: {mean_width_px:.1f} px')

    ax.set_xlabel('Width (pixels)', fontsize=12)
    ax.set_ylabel('Frequency', fontsize=12)
    ax.set_title('Distribution of Cross-Section Widths', fontsize=14, fontweight='bold')
    ax.legend(fontsize=10)
    ax.grid(True, alpha=0.3)

    # Save
    output_path = os.path.join(debug_dir, "12_width_distribution.png")
    plt.savefig(output_path, dpi=150, bbox_inches='tight')
    plt.close()


def compare_edge_methods(
    contour_result: Dict[str, Any],
    sobel_result: Dict[str, Any],
    scale_px_per_cm: float
) -> Dict[str, Any]:
    """
    Compare contour-based and Sobel-based edge detection methods.

    Provides detailed analysis of differences, quality metrics, and
    recommendation on which method to use.

    Args:
        contour_result: Output from compute_cross_section_width()
        sobel_result: Output from refine_edges_sobel()
        scale_px_per_cm: Scale factor for unit conversion

    Returns:
        Dictionary containing:
        - contour: Summary of contour method results
        - sobel: Summary of Sobel method results
        - difference: Comparison metrics
        - recommendation: Which method to use and why
        - quality_comparison: Quality metrics comparison
    """
    # Extract measurements
    contour_width_cm = contour_result["median_width_px"] / scale_px_per_cm
    sobel_width_cm = sobel_result["median_width_cm"]

    contour_width_px = contour_result["median_width_px"]
    sobel_width_px = sobel_result["median_width_px"]

    # Calculate differences
    diff_cm = sobel_width_cm - contour_width_cm
    diff_px = sobel_width_px - contour_width_px
    diff_pct = (diff_cm / contour_width_cm) * 100 if contour_width_cm > 0 else 0.0

    # Quality comparison
    contour_cv = (contour_result["std_width_px"] / contour_result["median_width_px"]) if contour_result["median_width_px"] > 0 else 0.0
    sobel_cv = (sobel_result["std_width_px"] / sobel_result["median_width_px"]) if sobel_result["median_width_px"] > 0 else 0.0

    # Determine recommendation
    should_use_sobel, reason = should_use_sobel_measurement(sobel_result, contour_result)

    # Build summary
    result = {
        "contour": {
            "width_cm": float(contour_width_cm),
            "width_px": float(contour_width_px),
            "std_dev_px": float(contour_result["std_width_px"]),
            "coefficient_variation": float(contour_cv),
            "num_samples": int(contour_result["num_samples"]),
            "method": "contour",
        },
        "sobel": {
            "width_cm": float(sobel_width_cm),
            "width_px": float(sobel_width_px),
            "std_dev_px": float(sobel_result["std_width_px"]),
            "coefficient_variation": float(sobel_cv),
            "num_samples": int(sobel_result["num_samples"]),
            "subpixel_used": bool(sobel_result["subpixel_refinement_used"]),
            "success_rate": float(sobel_result["edge_detection_success_rate"]),
            "edge_quality_score": float(sobel_result["edge_quality"]["overall_score"]),
            "method": "sobel",
        },
        "difference": {
            "absolute_cm": float(diff_cm),
            "absolute_px": float(diff_px),
            "relative_pct": float(diff_pct),
            "precision_improvement": float(contour_result["std_width_px"] - sobel_result["std_width_px"]),
        },
        "recommendation": {
            "use_sobel": bool(should_use_sobel),
            "reason": str(reason),
            "preferred_method": "sobel" if should_use_sobel else "contour",
        },
        "quality_comparison": {
            "contour_cv": float(contour_cv),
            "sobel_cv": float(sobel_cv),
            "sobel_quality_score": float(sobel_result["edge_quality"]["overall_score"]),
            "sobel_gradient_strength": float(sobel_result["edge_quality"]["gradient_strength_score"]),
            "sobel_consistency": float(sobel_result["edge_quality"]["consistency_score"]),
            "sobel_smoothness": float(sobel_result["edge_quality"]["smoothness_score"]),
            "sobel_symmetry": float(sobel_result["edge_quality"]["symmetry_score"]),
        },
    }

    return result