pequenos ajustes na calibragem e montagem do tensor

This commit is contained in:
Diego Freitas 2026-05-08 10:02:01 -03:00
parent e850864607
commit 7c5c8b54ea
10 changed files with 3063 additions and 564 deletions

View File

@ -407,7 +407,6 @@ def main():
"camera_params_json": args.module_calibration_json, "camera_params_json": args.module_calibration_json,
"note": "autosave", "note": "autosave",
"raw_preview_reference_camera": preview_source_id, "raw_preview_reference_camera": preview_source_id,
"patch_normalization_result": cam.get_last_patch_normalization_result()
} }
save_sample( save_sample(
@ -470,7 +469,6 @@ def main():
"camera_params_json": args.module_calibration_json, "camera_params_json": args.module_calibration_json,
"note": "manual", "note": "manual",
"raw_preview_reference_camera": preview_source_id, "raw_preview_reference_camera": preview_source_id,
"patch_normalization_result": cam.get_last_patch_normalization_result()
} }
save_sample( save_sample(

View File

@ -1,6 +1,6 @@
{ {
"schema": "manual_multispec_offsets_v2", "schema": "manual_multispec_offsets_v2",
"saved_at": "2026-05-06 09:54:59", "saved_at": "2026-05-08 09:17:15",
"frame_type": "RAW_BRUTO", "frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO", "capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO", "capture_mode_effective": "AUTO",
@ -26,35 +26,35 @@
"homographies": { "homographies": {
"re_to_rgb": [ "re_to_rgb": [
[ [
0.9836077983170345, 0.9891488988352489,
-0.00414576215755023, -0.0057973762346762115,
-7.995670153079967 -15.173511767385348
], ],
[ [
-0.009155951570825544, -0.015114489970267686,
0.9872093474367271, 0.9903282034674085,
65.61737450021248 58.93120393758602
], ],
[ [
-8.515127189657688e-06, -2.1528138783460464e-05,
-3.4530877754475106e-06, -1.5606269505459822e-05,
1.0 1.0
] ]
], ],
"nir_to_rgb": [ "nir_to_rgb": [
[ [
0.9950840771095427, 1.015255211995985,
-0.01203311522937068, -0.011641307565439515,
-0.37678489564225515 -15.440297021288165
], ],
[ [
0.005818513521498549, 0.006224807373165827,
0.9844634830519817, 1.0059040970450264,
40.58679770900771 21.05815660535078
], ],
[ [
1.1689856045746963e-05, 5.830335800966281e-06,
-1.643688958474221e-05, -1.5297165752796802e-05,
1.0 1.0
] ]
] ]

View File

@ -1,6 +1,6 @@
{ {
"schema": "multispec_module_params_v3", "schema": "multispec_module_params_v3",
"saved_at": "2026-05-07 14:41:12", "saved_at": "2026-05-08 09:23:01",
"frame_type": "RAW_BRUTO", "frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO", "capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO", "capture_mode_effective": "AUTO",
@ -53,35 +53,35 @@
"homographies": { "homographies": {
"re_to_rgb": [ "re_to_rgb": [
[ [
0.9836077983170345, 0.9891488988352489,
-0.00414576215755023, -0.0057973762346762115,
-7.995670153079967 -15.173511767385348
], ],
[ [
-0.009155951570825544, -0.015114489970267686,
0.9872093474367271, 0.9903282034674085,
65.61737450021248 58.93120393758602
], ],
[ [
-8.515127189657688e-06, -2.1528138783460464e-05,
-3.4530877754475106e-06, -1.5606269505459822e-05,
1.0 1.0
] ]
], ],
"nir_to_rgb": [ "nir_to_rgb": [
[ [
0.9950840771095427, 1.015255211995985,
-0.01203311522937068, -0.011641307565439515,
-0.37678489564225515 -15.440297021288165
], ],
[ [
0.005818513521498549, 0.006224807373165827,
0.9844634830519817, 1.0059040970450264,
40.58679770900771 21.05815660535078
], ],
[ [
1.1689856045746963e-05, 5.830335800966281e-06,
-1.643688958474221e-05, -1.5297165752796802e-05,
1.0 1.0
] ]
] ]
@ -92,7 +92,7 @@
}, },
"radiometric_config": { "radiometric_config": {
"enabled": true, "enabled": true,
"interval_s": 0.5, "interval_s": 0.25,
"verbose": true, "verbose": true,
"metering_mode": "reference_patches", "metering_mode": "reference_patches",
"spectral_control_mode": "shared", "spectral_control_mode": "shared",
@ -118,31 +118,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"target_value": 0.08, "target_value": 0.08,
"weight": 0.7, "weight": 0.7,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.403125, "x0": 0.395313,
"y0": 0.745, "y0": 0.745,
"x1": 0.459375, "x1": 0.4625,
"y1": 0.92 "y1": 0.9225
}, },
"nir": { "nir": {
"x0": 0.364063, "x0": 0.353125,
"y0": 0.775, "y0": 0.78,
"x1": 0.428125, "x1": 0.420312,
"y1": 0.9425 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -151,13 +151,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:47" "updated_at": "2026-05-08 09:19:46"
} }
], ],
"re": [ "re": [
@ -165,13 +165,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.403125, "x0": 0.395313,
"y0": 0.745, "y0": 0.745,
"x1": 0.459375, "x1": 0.4625,
"y1": 0.92 "y1": 0.9225
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:05" "updated_at": "2026-05-08 09:20:14"
} }
], ],
"nir": [ "nir": [
@ -179,13 +179,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.364063, "x0": 0.353125,
"y0": 0.775, "y0": 0.78,
"x1": 0.428125, "x1": 0.420312,
"y1": 0.9425 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:37" "updated_at": "2026-05-08 09:20:47"
} }
] ]
} }
@ -199,12 +199,12 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"target_value": 0.5, "target_value": 0.35,
"target_value_by_role": { "target_value_by_role": {
"rgb": 0.34, "rgb": 0.34,
"re": 0.24, "re": 0.24,
@ -213,22 +213,22 @@
"weight": 1.0, "weight": 1.0,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.332813, "x0": 0.325,
"y0": 0.745, "y0": 0.75,
"x1": 0.395313, "x1": 0.389062,
"y1": 0.915 "y1": 0.915
}, },
"nir": { "nir": {
"x0": 0.29375, "x0": 0.284375,
"y0": 0.775, "y0": 0.79,
"x1": 0.35625, "x1": 0.35,
"y1": 0.94 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -237,13 +237,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:15" "updated_at": "2026-05-08 09:19:29"
} }
], ],
"re": [ "re": [
@ -251,13 +251,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.332813, "x0": 0.325,
"y0": 0.745, "y0": 0.75,
"x1": 0.395313, "x1": 0.389062,
"y1": 0.915 "y1": 0.915
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:15" "updated_at": "2026-05-08 09:20:22"
} }
], ],
"nir": [ "nir": [
@ -265,13 +265,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.29375, "x0": 0.284375,
"y0": 0.775, "y0": 0.79,
"x1": 0.35625, "x1": 0.35,
"y1": 0.94 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:46" "updated_at": "2026-05-08 09:20:53"
} }
] ]
} }
@ -285,31 +285,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"target_value": 0.82, "target_value": 0.82,
"weight": 0.8, "weight": 0.8,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.2625, "x0": 0.25,
"y0": 0.7525, "y0": 0.7525,
"x1": 0.321875, "x1": 0.315625,
"y1": 0.9225 "y1": 0.9225
}, },
"nir": { "nir": {
"x0": 0.220312, "x0": 0.214062,
"y0": 0.775, "y0": 0.785,
"x1": 0.284375, "x1": 0.282813,
"y1": 0.9425 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -318,13 +318,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:38" "updated_at": "2026-05-08 09:19:04"
} }
], ],
"re": [ "re": [
@ -332,13 +332,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.2625, "x0": 0.25,
"y0": 0.7525, "y0": 0.7525,
"x1": 0.321875, "x1": 0.315625,
"y1": 0.9225 "y1": 0.9225
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:24" "updated_at": "2026-05-08 09:20:27"
} }
], ],
"nir": [ "nir": [
@ -346,13 +346,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.220312, "x0": 0.214062,
"y0": 0.775, "y0": 0.785,
"x1": 0.284375, "x1": 0.282813,
"y1": 0.9425 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:55" "updated_at": "2026-05-08 09:21:02"
} }
] ]
} }
@ -418,7 +418,7 @@
"patch_roi_max_p50_delta": 0.12 "patch_roi_max_p50_delta": 0.12
}, },
"radiometric_normalization": { "radiometric_normalization": {
"enabled": true, "enabled": false,
"method": "exposure_gain_reference", "method": "exposure_gain_reference",
"apply_stage": "after_dark_before_flat_gain", "apply_stage": "after_dark_before_flat_gain",
"reference_controls": { "reference_controls": {
@ -465,7 +465,7 @@
} }
}, },
"flatfield_config": { "flatfield_config": {
"enabled": true, "enabled": false,
"subtract_dark": true, "subtract_dark": true,
"schema": "multispec_flatfield_v1", "schema": "multispec_flatfield_v1",
"created_at": "2026-05-06 13:37:25", "created_at": "2026-05-06 13:37:25",

View File

@ -120,31 +120,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"target_value": 0.08, "target_value": 0.08,
"weight": 0.7, "weight": 0.7,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.403125, "x0": 0.395313,
"y0": 0.745, "y0": 0.745,
"x1": 0.459375, "x1": 0.4625,
"y1": 0.92 "y1": 0.9225
}, },
"nir": { "nir": {
"x0": 0.364063, "x0": 0.353125,
"y0": 0.775, "y0": 0.78,
"x1": 0.428125, "x1": 0.420312,
"y1": 0.9425 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -153,13 +153,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:47" "updated_at": "2026-05-08 09:19:46"
} }
], ],
"re": [ "re": [
@ -167,13 +167,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.403125, "x0": 0.395313,
"y0": 0.745, "y0": 0.745,
"x1": 0.459375, "x1": 0.4625,
"y1": 0.92 "y1": 0.9225
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:05" "updated_at": "2026-05-08 09:20:14"
} }
], ],
"nir": [ "nir": [
@ -181,13 +181,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.364063, "x0": 0.353125,
"y0": 0.775, "y0": 0.78,
"x1": 0.428125, "x1": 0.420312,
"y1": 0.9425 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:37" "updated_at": "2026-05-08 09:20:47"
} }
] ]
} }
@ -201,31 +201,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"target_value": 0.5, "target_value": 0.5,
"weight": 1.0, "weight": 1.0,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.332813, "x0": 0.325,
"y0": 0.745, "y0": 0.75,
"x1": 0.395313, "x1": 0.389062,
"y1": 0.915 "y1": 0.915
}, },
"nir": { "nir": {
"x0": 0.29375, "x0": 0.284375,
"y0": 0.775, "y0": 0.79,
"x1": 0.35625, "x1": 0.35,
"y1": 0.94 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -234,13 +234,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:15" "updated_at": "2026-05-08 09:19:29"
} }
], ],
"re": [ "re": [
@ -248,13 +248,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.332813, "x0": 0.325,
"y0": 0.745, "y0": 0.75,
"x1": 0.395313, "x1": 0.389062,
"y1": 0.915 "y1": 0.915
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:15" "updated_at": "2026-05-08 09:20:22"
} }
], ],
"nir": [ "nir": [
@ -262,13 +262,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.29375, "x0": 0.284375,
"y0": 0.775, "y0": 0.79,
"x1": 0.35625, "x1": 0.35,
"y1": 0.94 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:46" "updated_at": "2026-05-08 09:20:53"
} }
] ]
} }
@ -282,31 +282,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"target_value": 0.82, "target_value": 0.82,
"weight": 0.8, "weight": 0.8,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.2625, "x0": 0.25,
"y0": 0.7525, "y0": 0.7525,
"x1": 0.321875, "x1": 0.315625,
"y1": 0.9225 "y1": 0.9225
}, },
"nir": { "nir": {
"x0": 0.220312, "x0": 0.214062,
"y0": 0.775, "y0": 0.785,
"x1": 0.284375, "x1": 0.282813,
"y1": 0.9425 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -315,13 +315,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:38" "updated_at": "2026-05-08 09:19:04"
} }
], ],
"re": [ "re": [
@ -329,13 +329,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.2625, "x0": 0.25,
"y0": 0.7525, "y0": 0.7525,
"x1": 0.321875, "x1": 0.315625,
"y1": 0.9225 "y1": 0.9225
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:24" "updated_at": "2026-05-08 09:20:27"
} }
], ],
"nir": [ "nir": [
@ -343,13 +343,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.220312, "x0": 0.214062,
"y0": 0.775, "y0": 0.785,
"x1": 0.284375, "x1": 0.282813,
"y1": 0.9425 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:55" "updated_at": "2026-05-08 09:21:02"
} }
] ]
} }
@ -439,7 +439,7 @@
} }
}, },
"schema": "multispec_radiometric_config_profiles_v3", "schema": "multispec_radiometric_config_profiles_v3",
"saved_at": "2026-05-07 14:40:58", "saved_at": "2026-05-08 09:21:09",
"active_profile": "three_reference_patches_mode", "active_profile": "three_reference_patches_mode",
"patch_normalization": { "patch_normalization": {
"enabled": true, "enabled": true,
@ -488,31 +488,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"target_value": 0.08, "target_value": 0.08,
"weight": 0.7, "weight": 0.7,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.403125, "x0": 0.395313,
"y0": 0.745, "y0": 0.745,
"x1": 0.459375, "x1": 0.4625,
"y1": 0.92 "y1": 0.9225
}, },
"nir": { "nir": {
"x0": 0.364063, "x0": 0.353125,
"y0": 0.775, "y0": 0.78,
"x1": 0.428125, "x1": 0.420312,
"y1": 0.9425 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -521,13 +521,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.376563, "x0": 0.365625,
"y0": 0.81, "y0": 0.8225,
"x1": 0.440625, "x1": 0.432812,
"y1": 0.985 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:47" "updated_at": "2026-05-08 09:19:46"
} }
], ],
"re": [ "re": [
@ -535,13 +535,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.403125, "x0": 0.395313,
"y0": 0.745, "y0": 0.745,
"x1": 0.459375, "x1": 0.4625,
"y1": 0.92 "y1": 0.9225
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:05" "updated_at": "2026-05-08 09:20:14"
} }
], ],
"nir": [ "nir": [
@ -549,13 +549,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.364063, "x0": 0.353125,
"y0": 0.775, "y0": 0.78,
"x1": 0.428125, "x1": 0.420312,
"y1": 0.9425 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:37" "updated_at": "2026-05-08 09:20:47"
} }
] ]
} }
@ -569,31 +569,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"target_value": 0.5, "target_value": 0.5,
"weight": 1.0, "weight": 1.0,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.332813, "x0": 0.325,
"y0": 0.745, "y0": 0.75,
"x1": 0.395313, "x1": 0.389062,
"y1": 0.915 "y1": 0.915
}, },
"nir": { "nir": {
"x0": 0.29375, "x0": 0.284375,
"y0": 0.775, "y0": 0.79,
"x1": 0.35625, "x1": 0.35,
"y1": 0.94 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -602,13 +602,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.30625, "x0": 0.29375,
"y0": 0.8225, "y0": 0.825,
"x1": 0.370312, "x1": 0.3625,
"y1": 0.985 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:15" "updated_at": "2026-05-08 09:19:29"
} }
], ],
"re": [ "re": [
@ -616,13 +616,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.332813, "x0": 0.325,
"y0": 0.745, "y0": 0.75,
"x1": 0.395313, "x1": 0.389062,
"y1": 0.915 "y1": 0.915
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:15" "updated_at": "2026-05-08 09:20:22"
} }
], ],
"nir": [ "nir": [
@ -630,13 +630,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.29375, "x0": 0.284375,
"y0": 0.775, "y0": 0.79,
"x1": 0.35625, "x1": 0.35,
"y1": 0.94 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:46" "updated_at": "2026-05-08 09:20:53"
} }
] ]
} }
@ -650,31 +650,31 @@
"nir" "nir"
], ],
"roi_pct": { "roi_pct": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"target_value": 0.82, "target_value": 0.82,
"weight": 0.8, "weight": 0.8,
"roi_pct_by_role": { "roi_pct_by_role": {
"rgb": { "rgb": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"re": { "re": {
"x0": 0.2625, "x0": 0.25,
"y0": 0.7525, "y0": 0.7525,
"x1": 0.321875, "x1": 0.315625,
"y1": 0.9225 "y1": 0.9225
}, },
"nir": { "nir": {
"x0": 0.220312, "x0": 0.214062,
"y0": 0.775, "y0": 0.785,
"x1": 0.284375, "x1": 0.282813,
"y1": 0.9425 "y1": 0.9525
} }
}, },
"roi_list_by_role": { "roi_list_by_role": {
@ -683,13 +683,13 @@
"name": "rgb_legacy_01", "name": "rgb_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.232813, "x0": 0.220312,
"y0": 0.8225, "y0": 0.8225,
"x1": 0.3, "x1": 0.2875,
"y1": 0.99 "y1": 0.995
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:39:38" "updated_at": "2026-05-08 09:19:04"
} }
], ],
"re": [ "re": [
@ -697,13 +697,13 @@
"name": "re_legacy_01", "name": "re_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.2625, "x0": 0.25,
"y0": 0.7525, "y0": 0.7525,
"x1": 0.321875, "x1": 0.315625,
"y1": 0.9225 "y1": 0.9225
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:24" "updated_at": "2026-05-08 09:20:27"
} }
], ],
"nir": [ "nir": [
@ -711,13 +711,13 @@
"name": "nir_legacy_01", "name": "nir_legacy_01",
"enabled": true, "enabled": true,
"roi_pct": { "roi_pct": {
"x0": 0.220312, "x0": 0.214062,
"y0": 0.775, "y0": 0.785,
"x1": 0.284375, "x1": 0.282813,
"y1": 0.9425 "y1": 0.9525
}, },
"created_at": "2026-05-07 14:36:25", "created_at": "2026-05-07 14:36:25",
"updated_at": "2026-05-07 14:40:55" "updated_at": "2026-05-08 09:21:02"
} }
] ]
} }

View File

@ -68,12 +68,37 @@ class RawProcessorCore:
"apply_stage": "after_fusion", "apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard", "method": "gray_scale_with_white_guard",
"space": "multispec_tensor", "space": "multispec_tensor",
"targets": { "targets_by_patch_channel": {
"black": 0.06, "black": {
"gray": 0.40, "R": 0.06,
"white": 0.78, "G": 0.06,
"B": 0.06,
"RE": 0.06,
"NIR": 0.06
},
"gray": {
"R": 0.34,
"G": 0.34,
"B": 0.34,
"RE": 0.24,
"NIR": 0.30
},
"white": {
"R": 0.78,
"G": 0.78,
"B": 0.78,
"RE": 0.78,
"NIR": 0.78
}
}, },
"white_guard_max": 0.92, "white_guard_max": 0.92,
"white_guard_max_by_channel": {
"R": 0.92,
"G": 0.92,
"B": 0.92,
"RE": 0.88,
"NIR": 0.88
},
"scale_min": 0.35, "scale_min": 0.35,
"scale_max": 2.50, "scale_max": 2.50,
"clip_output": True, "clip_output": True,
@ -82,6 +107,7 @@ class RawProcessorCore:
"save_patch_stats": True, "save_patch_stats": True,
} }
self.last_patch_normalization_result = None self.last_patch_normalization_result = None
self.last_frame_quality_result = None
self.camera_settings = {} self.camera_settings = {}
if calibration_json_path: if calibration_json_path:
@ -284,6 +310,7 @@ class RawProcessorCore:
tensor = self.fuse_multispec_cameras(decoded, meta=None, channels_expected=len(channel_names)) tensor = self.fuse_multispec_cameras(decoded, meta=None, channels_expected=len(channel_names))
tensor = self.resize_tensor_chw(tensor, target_size=target_size) tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor) tensor = self.apply_patch_normalization_to_tensor(tensor)
self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
return tensor, channel_names return tensor, channel_names
@ -295,6 +322,7 @@ class RawProcessorCore:
tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected) tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.resize_tensor_chw(tensor, target_size=target_size) tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor) tensor = self.apply_patch_normalization_to_tensor(tensor)
self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
return tensor return tensor
if frame_type in ("RGB", "MULTISPEC"): if frame_type in ("RGB", "MULTISPEC"):
@ -318,6 +346,7 @@ class RawProcessorCore:
tensor = raw_np tensor = raw_np
tensor = self.resize_tensor_chw(tensor, target_size=target_size) tensor = self.resize_tensor_chw(tensor, target_size=target_size)
self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
return tensor return tensor
raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}") raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}")
@ -681,6 +710,10 @@ class RawProcessorCore:
"warnings": [], "warnings": [],
"scales": {}, "scales": {},
"patch_stats": {}, "patch_stats": {},
"channel_debug": {},
"tensor_stats_before": {},
"tensor_stats_after": {},
"summary": {},
} }
if not cfg.get("enabled", False): if not cfg.get("enabled", False):
@ -720,124 +753,106 @@ class RawProcessorCore:
self.last_patch_normalization_result = result self.last_patch_normalization_result = result
return tensor return tensor
targets = cfg.get("targets", {}) or {} targets_by_patch_channel = self._resolve_targets_by_patch_channel(cfg, rad_cfg)
gray_target = float(targets.get("gray", 0.40))
scale_min = float(cfg.get("scale_min", 0.35)) scale_min = float(cfg.get("scale_min", 0.35))
scale_max = float(cfg.get("scale_max", 2.50)) scale_max = float(cfg.get("scale_max", 2.50))
white_guard_max = float(cfg.get("white_guard_max", 0.92)) white_guard_max_global = float(cfg.get("white_guard_max", 0.92))
white_guard_by_channel = cfg.get("white_guard_max_by_channel", {}) or {}
clip_output = bool(cfg.get("clip_output", True)) clip_output = bool(cfg.get("clip_output", True))
channel_names = ["R", "G", "B", "RE", "NIR"] channel_names = ["R", "G", "B", "RE", "NIR"]
out = tensor.astype(np.float32).copy() out = tensor.astype(np.float32).copy()
h, w = out.shape[1], out.shape[2] h, w = out.shape[1], out.shape[2]
result["tensor_stats_before"] = self._tensor_channel_stats(out, channel_names)
def roi_from_patch(patch): # Para o tensor final fusionado, a geometria de referência é o espaço do RGB.
if not patch: # Como RE/NIR são alinhados por homografia para casar no RGB, as ROIs usadas
return None # na normalização final devem ser as ROIs da role rgb, com suporte a múltiplas
return self._roi_pct_to_pixels_from_patch(patch.get("roi_pct", {}) or {}, w, h) # ROIs por cor/cartão.
gray_rois = self._resolve_patch_tensor_rois(gray, w, h, reference_role="rgb")
white_rois = self._resolve_patch_tensor_rois(white, w, h, reference_role="rgb")
black_rois = self._resolve_patch_tensor_rois(black, w, h, reference_role="rgb")
gray_roi = roi_from_patch(gray) result["roi_source"] = "roi_list_by_role.rgb"
white_roi = roi_from_patch(white) result["roi_counts"] = {
black_roi = roi_from_patch(black) "black": len(black_rois),
"gray": len(gray_rois),
"white": len(white_rois),
}
if gray_roi is None: if not gray_rois:
result["warnings"].append("invalid_gray_roi") result["warnings"].append("missing_or_invalid_gray_rois")
self.last_patch_normalization_result = result self.last_patch_normalization_result = result
return tensor return tensor
for ci, ch_name in enumerate(channel_names): for ci, ch_name in enumerate(channel_names):
ch = out[ci] ch = out[ci]
input_stats = self._array01_stats(ch)
gray_target = float(targets_by_patch_channel["gray"][ch_name])
white_target = float(targets_by_patch_channel["white"][ch_name])
# ----------------------------- # -----------------------------
# Stats do gray # Stats do gray
# ----------------------------- # -----------------------------
gx0, gy0, gx1, gy1 = gray_roi gray_stats = self._measure_patch_rois_for_channel(ch, gray_rois)
gray_vals = ch[gy0:gy1, gx0:gx1].reshape(-1)
if gray_vals.size <= 0: if not gray_stats:
result["warnings"].append(f"{ch_name}: empty_gray_roi") result["warnings"].append(f"{ch_name}: empty_gray_rois")
continue continue
gray_p50 = float(np.percentile(gray_vals, 50)) result["patch_stats"].setdefault("gray", {})[ch_name] = gray_stats
gray_p05 = float(np.percentile(gray_vals, 5)) gray_p50 = float(gray_stats["p50"])
gray_p95 = float(np.percentile(gray_vals, 95))
gray_sat = float((gray_vals >= 0.98).mean() * 100.0)
gray_dark = float((gray_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("gray", {})[ch_name] = {
"p05": gray_p05,
"p50": gray_p50,
"p95": gray_p95,
"sat_pct": gray_sat,
"dark_pct": gray_dark,
"roi_px": list(gray_roi),
}
if gray_p50 <= 1e-6: if gray_p50 <= 1e-6:
result["warnings"].append(f"{ch_name}: gray_p50_too_low") result["warnings"].append(f"{ch_name}: gray_p50_too_low")
continue continue
scale = gray_target / gray_p50 scale_raw = float(gray_target / gray_p50)
scale = scale_raw
scale_after_white_guard = float(scale)
white_guard_ch = white_guard_max_global
if isinstance(white_guard_by_channel, dict) and ch_name in white_guard_by_channel:
try:
white_guard_ch = float(white_guard_by_channel[ch_name])
except Exception:
pass
# ----------------------------- # -----------------------------
# Stats do white + guarda # Stats do white + guarda
# ----------------------------- # -----------------------------
if white_roi is not None: if white_rois:
wx0, wy0, wx1, wy1 = white_roi white_stats = self._measure_patch_rois_for_channel(ch, white_rois)
white_vals = ch[wy0:wy1, wx0:wx1].reshape(-1)
if white_vals.size > 0: if white_stats:
white_p50 = float(np.percentile(white_vals, 50)) result["patch_stats"].setdefault("white", {})[ch_name] = white_stats
white_p05 = float(np.percentile(white_vals, 5))
white_p95 = float(np.percentile(white_vals, 95))
white_sat = float((white_vals >= 0.98).mean() * 100.0)
white_dark = float((white_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("white", {})[ch_name] = { white_p50 = float(white_stats["p50"])
"p05": white_p05, white_sat = float(white_stats["sat_pct"])
"p50": white_p50,
"p95": white_p95,
"sat_pct": white_sat,
"dark_pct": white_dark,
"roi_px": list(white_roi),
}
if white_sat > 0.5: if white_sat > 0.5:
result["warnings"].append(f"{ch_name}: white_patch_saturated_{white_sat:.2f}%") result["warnings"].append(f"{ch_name}: white_patch_saturated_{white_sat:.2f}%")
if white_p50 > 1e-6: if white_p50 > 1e-6:
max_scale_by_white = white_guard_max / white_p50 max_scale_by_white = white_guard_ch / white_p50
if scale > max_scale_by_white: if scale > max_scale_by_white:
result["warnings"].append( result["warnings"].append(
f"{ch_name}: scale_limited_by_white_guard " f"{ch_name}: scale_limited_by_white_guard "
f"{scale:.3f}->{max_scale_by_white:.3f}" f"{scale:.3f}->{max_scale_by_white:.3f}"
) )
scale = min(scale, max_scale_by_white) scale = min(scale, max_scale_by_white)
scale_after_white_guard = float(scale)
# ----------------------------- # -----------------------------
# Stats do black, só diagnóstico # Stats do black, só diagnóstico
# ----------------------------- # -----------------------------
if black_roi is not None: if black_rois:
bx0, by0, bx1, by1 = black_roi black_stats = self._measure_patch_rois_for_channel(ch, black_rois)
black_vals = ch[by0:by1, bx0:bx1].reshape(-1) if black_stats:
result["patch_stats"].setdefault("black", {})[ch_name] = black_stats
if black_vals.size > 0:
black_p50 = float(np.percentile(black_vals, 50))
black_p05 = float(np.percentile(black_vals, 5))
black_p95 = float(np.percentile(black_vals, 95))
black_sat = float((black_vals >= 0.98).mean() * 100.0)
black_dark = float((black_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("black", {})[ch_name] = {
"p05": black_p05,
"p50": black_p50,
"p95": black_p95,
"sat_pct": black_sat,
"dark_pct": black_dark,
"roi_px": list(black_roi),
}
scale_before_clip = float(scale) scale_before_clip = float(scale)
scale = float(np.clip(scale, scale_min, scale_max)) scale = float(np.clip(scale, scale_min, scale_max))
@ -847,17 +862,59 @@ class RawProcessorCore:
f"{ch_name}: scale_clipped {scale_before_clip:.3f}->{scale:.3f}" f"{ch_name}: scale_clipped {scale_before_clip:.3f}->{scale:.3f}"
) )
out[ci] = ch * scale scaled = ch * scale
output_before_clip_stats = self._array01_stats(scaled)
would_clip_low_pct = float((scaled < 0.0).mean() * 100.0)
would_clip_high_pct = float((scaled > 1.0).mean() * 100.0)
would_clip_pct = would_clip_low_pct + would_clip_high_pct
if clip_output:
out_ch = np.clip(scaled, 0.0, 1.0)
else:
out_ch = scaled
output_after_clip_stats = self._array01_stats(out_ch)
out[ci] = out_ch
result["scales"][ch_name] = { result["scales"][ch_name] = {
"scale": scale, "scale": scale,
"scale_raw_gray": scale_raw,
"scale_after_white_guard": scale_after_white_guard,
"scale_before_clip_limits": scale_before_clip,
"scale_min": scale_min,
"scale_max": scale_max,
"gray_target": gray_target, "gray_target": gray_target,
"gray_measured_p50": gray_p50, "gray_measured_p50": gray_p50,
"white_target": white_target,
"white_guard_max": white_guard_ch,
"would_clip_pct": would_clip_pct,
"would_clip_high_pct": would_clip_high_pct,
"would_clip_low_pct": would_clip_low_pct,
}
result["channel_debug"][ch_name] = {
"input_stats": input_stats,
"output_before_clip_stats": output_before_clip_stats,
"output_after_clip_stats": output_after_clip_stats,
"gray_target": gray_target,
"white_target": white_target,
"white_guard_max": white_guard_ch,
"gray_p50": gray_p50,
"scale_raw_gray": scale_raw,
"scale_after_white_guard": scale_after_white_guard,
"scale_before_clip_limits": scale_before_clip,
"scale_final": scale,
"would_clip_pct": would_clip_pct,
"would_clip_high_pct": would_clip_high_pct,
"would_clip_low_pct": would_clip_low_pct,
} }
if clip_output: if clip_output:
out = np.clip(out, 0.0, 1.0) out = np.clip(out, 0.0, 1.0)
result["tensor_stats_after"] = self._tensor_channel_stats(out, channel_names)
result["summary"] = self._summarize_patch_normalization_result(result)
result["applied"] = True result["applied"] = True
result["valid"] = bool(len(result["scales"]) == len(channel_names)) result["valid"] = bool(len(result["scales"]) == len(channel_names))
result["clip_output"] = clip_output result["clip_output"] = clip_output
@ -867,6 +924,372 @@ class RawProcessorCore:
self.last_patch_normalization_result = result self.last_patch_normalization_result = result
return out.astype(np.float32, copy=False) return out.astype(np.float32, copy=False)
def _array01_stats(self, arr: np.ndarray) -> dict:
"""
Estatísticas compactas para debug radiométrico de arrays float.
Mantém tudo serializável em JSON e leve o bastante para log por frame.
"""
if arr is None:
return {}
vals = np.asarray(arr, dtype=np.float32).reshape(-1)
if vals.size <= 0:
return {}
finite = vals[np.isfinite(vals)]
if finite.size <= 0:
return {"count": int(vals.size), "finite_count": 0}
return {
"count": int(vals.size),
"finite_count": int(finite.size),
"min": float(np.min(finite)),
"p01": float(np.percentile(finite, 1)),
"p05": float(np.percentile(finite, 5)),
"p50": float(np.percentile(finite, 50)),
"p95": float(np.percentile(finite, 95)),
"p99": float(np.percentile(finite, 99)),
"max": float(np.max(finite)),
"mean": float(np.mean(finite)),
"std": float(np.std(finite)),
"sat_pct": float((finite >= 0.98).mean() * 100.0),
"dark_pct": float((finite <= 0.02).mean() * 100.0),
"over_1_pct": float((finite > 1.0).mean() * 100.0),
"under_0_pct": float((finite < 0.0).mean() * 100.0),
}
def _tensor_channel_stats(self, tensor: np.ndarray, channel_names: list) -> dict:
if tensor is None or tensor.ndim != 3:
return {}
stats = {}
for i, ch_name in enumerate(channel_names):
if i >= tensor.shape[0]:
break
stats[ch_name] = self._array01_stats(tensor[i])
return stats
def _summarize_patch_normalization_result(self, result: dict) -> dict:
scales = result.get("scales", {}) or {}
patch_stats = result.get("patch_stats", {}) or {}
scale_values = [float(v.get("scale", 1.0)) for v in scales.values() if isinstance(v, dict)]
clip_values = [float(v.get("would_clip_pct", 0.0)) for v in scales.values() if isinstance(v, dict)]
white_stats = patch_stats.get("white", {}) if isinstance(patch_stats, dict) else {}
gray_stats = patch_stats.get("gray", {}) if isinstance(patch_stats, dict) else {}
max_white_sat = 0.0
if isinstance(white_stats, dict):
vals = [float(v.get("sat_pct", 0.0)) for v in white_stats.values() if isinstance(v, dict)]
max_white_sat = max(vals) if vals else 0.0
max_gray_sat = 0.0
if isinstance(gray_stats, dict):
vals = [float(v.get("sat_pct", 0.0)) for v in gray_stats.values() if isinstance(v, dict)]
max_gray_sat = max(vals) if vals else 0.0
return {
"scale_min_applied": float(min(scale_values)) if scale_values else None,
"scale_max_applied": float(max(scale_values)) if scale_values else None,
"max_would_clip_pct": float(max(clip_values)) if clip_values else 0.0,
"max_white_sat_pct": float(max_white_sat),
"max_gray_sat_pct": float(max_gray_sat),
"warning_count": int(len(result.get("warnings", []) or [])),
"valid_channel_count": int(len(scales)),
}
def evaluate_frame_quality(self, tensor: np.ndarray | None = None) -> dict:
"""
Avalia a qualidade do tensor final gerado pelo RawProcessorCore.
Esta avaliação pertence ao processamento do tensor, não à captura RAW_BRUTO.
Ela combina:
- estatísticas do tensor final por canal;
- resumo do patch_normalization;
- warnings radiométricos/normalização.
Status:
good -> tensor utilizável normalmente;
warning -> tensor utilizável, mas com cautela/auditoria;
bad -> tensor não recomendado para treino/inferência crítica.
"""
channel_names = ["R", "G", "B", "RE", "NIR"]
quality = {
"status": "good",
"usable_for_training": True,
"usable_for_inference": True,
"reasons": [],
"metrics": {},
"thresholds": {
"tensor_sat_warning_pct": 0.5,
"tensor_sat_bad_pct": 5.0,
"tensor_dark_warning_pct": 45.0,
"tensor_dark_bad_pct": 75.0,
"patch_white_sat_warning_pct": 0.5,
"patch_white_sat_bad_pct": 20.0,
"patch_clip_warning_pct": 0.5,
"patch_clip_bad_pct": 5.0,
},
}
def mark(level: str, reason: str):
ranks = {"good": 0, "warning": 1, "bad": 2}
if ranks.get(level, 0) > ranks.get(quality["status"], 0):
quality["status"] = level
if reason not in quality["reasons"]:
quality["reasons"].append(reason)
if tensor is None or not isinstance(tensor, np.ndarray) or tensor.ndim != 3:
mark("bad", "invalid_tensor")
quality["usable_for_training"] = False
quality["usable_for_inference"] = False
return quality
stats = self._tensor_channel_stats(tensor, channel_names)
quality["metrics"]["tensor_stats"] = stats
sat_values = [float(v.get("sat_pct", 0.0)) for v in stats.values() if isinstance(v, dict)]
dark_values = [float(v.get("dark_pct", 0.0)) for v in stats.values() if isinstance(v, dict)]
over_values = [float(v.get("over_1_pct", 0.0)) for v in stats.values() if isinstance(v, dict)]
under_values = [float(v.get("under_0_pct", 0.0)) for v in stats.values() if isinstance(v, dict)]
max_tensor_sat = max(sat_values) if sat_values else 0.0
max_tensor_dark = max(dark_values) if dark_values else 0.0
max_tensor_over = max(over_values) if over_values else 0.0
max_tensor_under = max(under_values) if under_values else 0.0
quality["metrics"]["max_tensor_sat_pct"] = float(max_tensor_sat)
quality["metrics"]["max_tensor_dark_pct"] = float(max_tensor_dark)
quality["metrics"]["max_tensor_over_1_pct"] = float(max_tensor_over)
quality["metrics"]["max_tensor_under_0_pct"] = float(max_tensor_under)
th = quality["thresholds"]
if max_tensor_sat >= th["tensor_sat_bad_pct"]:
mark("bad", f"tensor_saturation_high:{max_tensor_sat:.2f}%")
elif max_tensor_sat >= th["tensor_sat_warning_pct"]:
mark("warning", f"tensor_saturation_warning:{max_tensor_sat:.2f}%")
if max_tensor_dark >= th["tensor_dark_bad_pct"]:
mark("bad", f"tensor_too_dark:{max_tensor_dark:.2f}%")
elif max_tensor_dark >= th["tensor_dark_warning_pct"]:
mark("warning", f"tensor_dark_warning:{max_tensor_dark:.2f}%")
patch_result = self.last_patch_normalization_result or {}
quality["metrics"]["patch_normalization_summary"] = patch_result.get("summary", {}) if isinstance(patch_result, dict) else {}
if isinstance(patch_result, dict):
if patch_result.get("enabled", False) and not patch_result.get("applied", False):
mark("bad", "patch_normalization_not_applied")
summary = patch_result.get("summary", {}) or {}
max_white_sat = float(summary.get("max_white_sat_pct", 0.0) or 0.0)
max_clip = float(summary.get("max_would_clip_pct", 0.0) or 0.0)
warning_count = int(summary.get("warning_count", 0) or 0)
valid_channel_count = int(summary.get("valid_channel_count", 0) or 0)
quality["metrics"]["max_patch_white_sat_pct"] = max_white_sat
quality["metrics"]["max_patch_would_clip_pct"] = max_clip
quality["metrics"]["patch_warning_count"] = warning_count
quality["metrics"]["patch_valid_channel_count"] = valid_channel_count
if max_white_sat >= th["patch_white_sat_bad_pct"]:
mark("bad", f"patch_white_saturation_high:{max_white_sat:.2f}%")
elif max_white_sat >= th["patch_white_sat_warning_pct"]:
mark("warning", f"patch_white_saturation_warning:{max_white_sat:.2f}%")
if max_clip >= th["patch_clip_bad_pct"]:
mark("bad", f"patch_output_clip_high:{max_clip:.2f}%")
elif max_clip >= th["patch_clip_warning_pct"]:
mark("warning", f"patch_output_clip_warning:{max_clip:.2f}%")
if warning_count > 0:
mark("warning", f"patch_warnings:{warning_count}")
if valid_channel_count not in (0, len(channel_names)):
mark("bad", f"patch_valid_channels_incomplete:{valid_channel_count}")
warnings = patch_result.get("warnings", []) or []
hard_warning_tokens = (
"missing_or_invalid_gray_rois",
"missing_gray_patch",
"invalid_tensor_shape",
"gray_p50_too_low",
"empty_gray_rois",
)
for w in warnings:
ws = str(w)
if any(tok in ws for tok in hard_warning_tokens):
mark("bad", f"patch_error:{ws}")
if quality["status"] == "bad":
quality["usable_for_training"] = False
quality["usable_for_inference"] = False
elif quality["status"] == "warning":
quality["usable_for_training"] = True
quality["usable_for_inference"] = True
quality["requires_review"] = True
else:
quality["requires_review"] = False
return quality
def _resolve_targets_by_patch_channel(self, cfg: dict, rad_cfg: dict) -> dict:
"""
Resolve os alvos radiométricos por tipo de referência e por canal CHW.
Contrato atual:
patch_normalization.targets_by_patch_channel
Formato esperado:
{
"black": {"R": 0.06, "G": 0.06, "B": 0.06, "RE": 0.06, "NIR": 0.06},
"gray": {"R": 0.34, "G": 0.34, "B": 0.34, "RE": 0.24, "NIR": 0.30},
"white": {"R": 0.78, "G": 0.78, "B": 0.78, "RE": 0.78, "NIR": 0.78}
}
Observação:
Por enquanto, o método gray_scale_with_white_guard usa principalmente:
- gray: alvo principal para calcular escala
- white: alvo/guarda para limitar escala
- black: reservado para futura correção com offset ou linearização
"""
channel_names = ["R", "G", "B", "RE", "NIR"]
# Valores seguros caso algo falte no JSON atual.
default_targets = {
"black": {
"R": 0.06,
"G": 0.06,
"B": 0.06,
"RE": 0.06,
"NIR": 0.06,
},
"gray": {
"R": 0.34,
"G": 0.34,
"B": 0.34,
"RE": 0.24,
"NIR": 0.30,
},
"white": {
"R": 0.78,
"G": 0.78,
"B": 0.78,
"RE": 0.78,
"NIR": 0.78,
},
}
out = {
patch_type: dict(values)
for patch_type, values in default_targets.items()
}
explicit = (cfg or {}).get("targets_by_patch_channel", {}) or {}
if isinstance(explicit, dict):
for patch_type in ("black", "gray", "white"):
patch_targets = explicit.get(patch_type, {}) or {}
if not isinstance(patch_targets, dict):
continue
for ch in channel_names:
if ch not in patch_targets:
continue
try:
out[patch_type][ch] = float(patch_targets[ch])
except Exception:
pass
return out
def _resolve_patch_tensor_rois(self, patch: dict, w: int, h: int, reference_role: str = "rgb") -> list:
"""
Resolve as ROIs usadas pelo patch_normalization no tensor final.
Contrato atual escolhido:
- O tensor final é alinhado no espaço do RGB.
- Portanto, as ROIs dos cartões no tensor final vêm de:
reference_patches[type].roi_list_by_role["rgb"]
- Suporta N ROIs por cor/cartão.
"""
if not isinstance(patch, dict):
return []
roi_list_by_role = patch.get("roi_list_by_role", {}) or {}
roi_items = roi_list_by_role.get(reference_role, []) or []
if not isinstance(roi_items, list):
roi_items = []
rois = []
for idx, item in enumerate(roi_items):
if not isinstance(item, dict):
continue
if not bool(item.get("enabled", True)):
continue
roi_pct = item.get("roi_pct", {}) or {}
if not isinstance(roi_pct, dict):
continue
roi_px = self._roi_pct_to_pixels_from_patch(roi_pct, w, h)
x0, y0, x1, y1 = roi_px
if x1 <= x0 or y1 <= y0:
continue
rois.append({
"name": str(item.get("name") or f"{reference_role}_roi_{idx + 1:02d}"),
"roi_pct": dict(roi_pct),
"roi_px": roi_px,
})
return rois
def _measure_patch_rois_for_channel(self, ch: np.ndarray, rois: list) -> dict | None:
"""
Mede uma lista de ROIs no canal CHW fusionado e reduz de forma robusta.
Com múltiplas ROIs, usamos mediana dos p50/p05/p95 e máximo de saturação/dark
para manter a normalização estável sem ignorar ROI problemática.
"""
roi_results = []
for roi in rois or []:
x0, y0, x1, y1 = roi["roi_px"]
vals = ch[y0:y1, x0:x1].reshape(-1)
if vals.size <= 0:
continue
stats = {
"name": roi.get("name"),
"roi_px": list(roi["roi_px"]),
"p05": float(np.percentile(vals, 5)),
"p50": float(np.percentile(vals, 50)),
"p95": float(np.percentile(vals, 95)),
"sat_pct": float((vals >= 0.98).mean() * 100.0),
"dark_pct": float((vals <= 0.02).mean() * 100.0),
}
roi_results.append(stats)
if not roi_results:
return None
return {
"roi_count": len(rois or []),
"valid_roi_count": len(roi_results),
"p05": float(np.median([r["p05"] for r in roi_results])),
"p50": float(np.median([r["p50"] for r in roi_results])),
"p95": float(np.median([r["p95"] for r in roi_results])),
"sat_pct": float(max(r["sat_pct"] for r in roi_results)),
"dark_pct": float(max(r["dark_pct"] for r in roi_results)),
"roi_results": roi_results,
}
def _roi_pct_to_pixels_from_patch(self, roi_pct: dict, w: int, h: int): def _roi_pct_to_pixels_from_patch(self, roi_pct: dict, w: int, h: int):
x0 = int(float(roi_pct.get("x0", 0.0)) * w) x0 = int(float(roi_pct.get("x0", 0.0)) * w)
y0 = int(float(roi_pct.get("y0", 0.0)) * h) y0 = int(float(roi_pct.get("y0", 0.0)) * h)

View File

@ -11,6 +11,7 @@ class RawProcessorCore:
self.sensor_width = sensor_width self.sensor_width = sensor_width
self.sensor_height = sensor_height self.sensor_height = sensor_height
self.bayer_pattern = bayer_pattern.upper() self.bayer_pattern = bayer_pattern.upper()
self.fusion_config = { self.fusion_config = {
"alignment_mode": "manual_affine", "alignment_mode": "manual_affine",
"baseline_mm": 75.0, "baseline_mm": 75.0,
@ -26,6 +27,7 @@ class RawProcessorCore:
"resize_after_crop": True, "resize_after_crop": True,
"target_size": None, "target_size": None,
} }
self.rgb_calibration = { self.rgb_calibration = {
"enabled": False, "enabled": False,
"gains": { "gains": {
@ -34,8 +36,56 @@ class RawProcessorCore:
"B": 1.0 "B": 1.0
} }
} }
self.calibration_json_path = calibration_json_path
self.calibration_base_dir = os.path.dirname(os.path.abspath(calibration_json_path)) if calibration_json_path else os.getcwd()
self.flatfield_config = {
"enabled": False,
"npz_file": None,
"apply_before_fusion": True,
"apply_after_decode": True,
"apply_space": "native_camera_space",
"map_type": "gain",
"channels": ["R", "G", "B", "RE", "NIR"],
"channel_maps": {},
"subtract_dark": False,
"clip_output": True,
}
self.flatfield_maps = {}
self.flatfield_loaded = False
self.radiometric_normalization_config = {
"enabled": False,
"method": "exposure_gain_reference",
"reference_controls": {},
"clip_output": False,
}
self.radiometric_config = {}
self.patch_normalization_config = {
"enabled": False,
"apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard",
"space": "multispec_tensor",
"targets": {
"black": 0.06,
"gray": 0.40,
"white": 0.78,
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.50,
"clip_output": True,
"require_valid_gray": True,
"use_black_for_offset": False,
"save_patch_stats": True,
}
self.last_patch_normalization_result = None
self.camera_settings = {}
if calibration_json_path: if calibration_json_path:
self.load_fusion_config_json(calibration_json_path) self.load_config_json(calibration_json_path)
def unpack_raw10_packed( def unpack_raw10_packed(
self, self,
@ -231,107 +281,23 @@ class RawProcessorCore:
raise RuntimeError("RGB obrigatório") raise RuntimeError("RGB obrigatório")
channel_names = self._channel_names_from_decoded(decoded) channel_names = self._channel_names_from_decoded(decoded)
tensor = self.fuse_multispec_cameras(decoded, meta=None, channels_expected=len(channel_names))
tensor = self.fuse_multispec_cameras(
decoded,
meta=None,
channels_expected=len(channel_names)
)
tensor = self.resize_tensor_chw(tensor, target_size=target_size) tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor)
return tensor, channel_names return tensor, channel_names
def build_infer_tensor_from_stream_old(self, frame, meta, channels_expected): def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None):
"""
Converte o frame vindo do stream do Pi em tensor (C,H,W) float32 0..1
compatível com o modelo.
Suporta:
- RGB uint8/float32 pronto
- MULTISPEC uint8/float32 pronto
- RAW_BRUTO multi_payload (cam2 RGB + cam0/cam1 packed)
"""
frame_type = meta.get("frame_type") frame_type = meta.get("frame_type")
dtype_str = meta.get("dtype") or meta.get("output_dtype", "uint8")
camera_frames = meta.get("camera_frames", {}) or {}
# -------------------------------------------------
# RAW_BRUTO multi_payload
# -------------------------------------------------
if frame_type == "RAW_BRUTO": if frame_type == "RAW_BRUTO":
if not isinstance(frame, dict): decoded = self.decode_stream_cameras(frame, meta)
raise RuntimeError("RAW_BRUTO esperado como dict de câmeras no modo multi") tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.resize_tensor_chw(tensor, target_size=target_size)
tensor = self.apply_patch_normalization_to_tensor(tensor)
return tensor
arrays = [] if frame_type in ("RGB", "MULTISPEC"):
channel_names = []
# RGB USB
if "cam2" in frame:
rgb_bgr = frame["cam2"]
if rgb_bgr.ndim != 3 or rgb_bgr.shape[2] != 3:
raise RuntimeError(f"cam2 RGB inválida: shape={rgb_bgr.shape}")
rgb = rgb_bgr[:, :, ::-1].astype(np.float32) / 255.0
rgb_chw = np.transpose(rgb, (2, 0, 1))
arrays.append(rgb_chw)
channel_names.extend(["R", "G", "B"])
else:
raise RuntimeError("RAW_BRUTO para inferência precisa incluir cam2 (RGB)")
# RE / NIR
for cam_id, spec_name in (("cam0", "RE"), ("cam1", "NIR")):
if cam_id not in frame:
continue
packed = frame[cam_id]
if packed.ndim == 3 and packed.shape[2] == 1:
packed = packed[:, :, 0]
cam_meta = camera_frames.get(cam_id, {})
packed_width = int(cam_meta.get("width", packed.shape[1]))
height = int(cam_meta.get("height", packed.shape[0]))
bayer = cam_meta.get("bayer_pattern", self.bayer_pattern)
bit_depth = int(cam_meta.get("bit_depth", 10))
if bit_depth == 10:
real_width = int((packed_width * 8) / 10)
else:
real_width = packed_width
rp = RawProcessorCore(
sensor_width=real_width,
sensor_height=height,
bayer_pattern=bayer,
)
raw16 = rp.unpack_raw10_packed(packed)
max_val = float((1 << bit_depth) - 1)
single = np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0)[None, :, :]
arrays.append(single)
channel_names.append(spec_name)
if len(arrays) < 2:
raise RuntimeError("RAW_BRUTO requer RGB + pelo menos um canal espectral para inferência")
min_h = min(a.shape[1] for a in arrays)
min_w = min(a.shape[2] for a in arrays)
arrays = [a[:, :min_h, :min_w] for a in arrays]
raw_np = np.concatenate(arrays, axis=0)
if raw_np.shape[0] != channels_expected:
raise RuntimeError(
f"Tensor RAW_BRUTO montado com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected} | got={channel_names}"
)
return raw_np
# -------------------------------------------------
# RGB ou MULTISPEC já pronto
# -------------------------------------------------
if frame_type == "RGB" or frame_type == "MULTISPEC":
if not isinstance(frame, np.ndarray): if not isinstance(frame, np.ndarray):
raise RuntimeError(f"Frame {frame_type} esperado como ndarray") raise RuntimeError(f"Frame {frame_type} esperado como ndarray")
@ -348,26 +314,11 @@ class RawProcessorCore:
raise RuntimeError(f"dtype {frame_type} não suportado: {dtype_str}") raise RuntimeError(f"dtype {frame_type} não suportado: {dtype_str}")
if raw_np.shape[0] != channels_expected: if raw_np.shape[0] != channels_expected:
raise RuntimeError( raise RuntimeError(f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}")
f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}" tensor = raw_np
)
return raw_np tensor = self.resize_tensor_chw(tensor, target_size=target_size)
return tensor
raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}")
def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None):
frame_type = meta.get("frame_type")
if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(frame, meta)
tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
return self.resize_tensor_chw(tensor, target_size=target_size)
if frame_type in ("RGB", "MULTISPEC"):
tensor = self.build_infer_tensor_from_stream_old(frame, meta, channels_expected)
return self.resize_tensor_chw(tensor, target_size=target_size)
raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}") raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}")
@ -450,7 +401,63 @@ class RawProcessorCore:
return decoded return decoded
def _decode_spectral_frame_to_float01(self, data, cam_meta):
arr = data
if arr.ndim == 3 and arr.shape[2] == 1:
arr = arr[:, :, 0]
bit_depth = int(cam_meta.get("bit_depth", 8))
raw_format = str(cam_meta.get("raw_format", "")).upper()
packed = bool(cam_meta.get("packed", False))
channels = int(cam_meta.get("channels", 1)) if cam_meta.get("channels") is not None else 1
sensor_width = int(cam_meta.get("width", self.sensor_width))
sensor_height = int(cam_meta.get("height", arr.shape[0]))
packed_width = int(cam_meta.get("packed_width", 0) or 0)
looks_like_raw10_packed = (
arr.ndim == 2
and arr.dtype == np.uint8
and arr.shape[0] == sensor_height
and (
raw_format == "RAW10_PACKED"
or packed
or bit_depth == 10
or (packed_width > 0 and arr.shape[1] == packed_width and packed_width != sensor_width)
or arr.shape[1] == int(sensor_width * 10 / 8)
)
)
if looks_like_raw10_packed:
raw16 = self.unpack_raw10_packed(
arr,
sensor_width=sensor_width,
sensor_height=sensor_height,
)
max_val = float((1 << bit_depth) - 1)
return np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0)
# Caso preview/processado: mono já vem uint8 normal.
if arr.ndim == 2 and arr.dtype == np.uint8:
return np.clip(arr.astype(np.float32) / 255.0, 0.0, 1.0)
if arr.ndim == 2 and arr.dtype == np.uint16:
max_val = float((1 << bit_depth) - 1) if bit_depth > 0 and bit_depth <= 16 else 65535.0
return np.clip(arr.astype(np.float32) / max_val, 0.0, 1.0)
arr01 = arr.astype(np.float32)
if arr01.max() > 1.5:
arr01 /= 255.0
return np.clip(arr01, 0.0, 1.0)
def fuse_multispec_cameras(self, decoded, meta, channels_expected): def fuse_multispec_cameras(self, decoded, meta, channels_expected):
decoded = self.apply_dark_to_decoded(decoded)
decoded = self.normalize_decoded_by_capture_controls(decoded, meta)
decoded = self.apply_flat_gain_to_decoded(decoded)
rgb_cam_id = self._find_cam_by_role(decoded, "rgb") rgb_cam_id = self._find_cam_by_role(decoded, "rgb")
if rgb_cam_id is None: if rgb_cam_id is None:
raise RuntimeError("Fusão requer câmera com role='rgb' como referência") raise RuntimeError("Fusão requer câmera com role='rgb' como referência")
@ -661,6 +668,218 @@ class RawProcessorCore:
return np.stack(chans, axis=0) return np.stack(chans, axis=0)
def apply_patch_normalization_to_tensor(self, tensor: np.ndarray) -> np.ndarray:
self.last_patch_normalization_result = None
cfg = self.patch_normalization_config or {}
result = {
"enabled": bool(cfg.get("enabled", False)),
"applied": False,
"method": cfg.get("method", "gray_scale_with_white_guard"),
"space": cfg.get("space", "multispec_tensor"),
"warnings": [],
"scales": {},
"patch_stats": {},
}
if not cfg.get("enabled", False):
result["warnings"].append("patch_normalization_disabled")
self.last_patch_normalization_result = result
return tensor
rad_cfg = self.radiometric_config or {}
if cfg.get("apply_when_metering_mode") == "reference_patches":
if rad_cfg.get("metering_mode") != "reference_patches":
result["warnings"].append(
f"metering_mode_not_reference_patches: {rad_cfg.get('metering_mode')}"
)
self.last_patch_normalization_result = result
return tensor
if tensor is None or tensor.ndim != 3 or tensor.shape[0] < 5:
result["warnings"].append(f"invalid_tensor_shape: {None if tensor is None else tensor.shape}")
self.last_patch_normalization_result = result
return tensor
patches = rad_cfg.get("reference_patches", []) or []
patch_by_type = {
str(p.get("type", "")).lower(): p
for p in patches
if isinstance(p, dict)
}
gray = patch_by_type.get("gray")
white = patch_by_type.get("white")
black = patch_by_type.get("black")
if gray is None:
result["warnings"].append("missing_gray_patch")
if cfg.get("require_valid_gray", True):
self.last_patch_normalization_result = result
return tensor
targets = cfg.get("targets", {}) or {}
gray_target = float(targets.get("gray", 0.40))
scale_min = float(cfg.get("scale_min", 0.35))
scale_max = float(cfg.get("scale_max", 2.50))
white_guard_max = float(cfg.get("white_guard_max", 0.92))
clip_output = bool(cfg.get("clip_output", True))
channel_names = ["R", "G", "B", "RE", "NIR"]
out = tensor.astype(np.float32).copy()
h, w = out.shape[1], out.shape[2]
def roi_from_patch(patch):
if not patch:
return None
return self._roi_pct_to_pixels_from_patch(patch.get("roi_pct", {}) or {}, w, h)
gray_roi = roi_from_patch(gray)
white_roi = roi_from_patch(white)
black_roi = roi_from_patch(black)
if gray_roi is None:
result["warnings"].append("invalid_gray_roi")
self.last_patch_normalization_result = result
return tensor
for ci, ch_name in enumerate(channel_names):
ch = out[ci]
# -----------------------------
# Stats do gray
# -----------------------------
gx0, gy0, gx1, gy1 = gray_roi
gray_vals = ch[gy0:gy1, gx0:gx1].reshape(-1)
if gray_vals.size <= 0:
result["warnings"].append(f"{ch_name}: empty_gray_roi")
continue
gray_p50 = float(np.percentile(gray_vals, 50))
gray_p05 = float(np.percentile(gray_vals, 5))
gray_p95 = float(np.percentile(gray_vals, 95))
gray_sat = float((gray_vals >= 0.98).mean() * 100.0)
gray_dark = float((gray_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("gray", {})[ch_name] = {
"p05": gray_p05,
"p50": gray_p50,
"p95": gray_p95,
"sat_pct": gray_sat,
"dark_pct": gray_dark,
"roi_px": list(gray_roi),
}
if gray_p50 <= 1e-6:
result["warnings"].append(f"{ch_name}: gray_p50_too_low")
continue
scale = gray_target / gray_p50
# -----------------------------
# Stats do white + guarda
# -----------------------------
if white_roi is not None:
wx0, wy0, wx1, wy1 = white_roi
white_vals = ch[wy0:wy1, wx0:wx1].reshape(-1)
if white_vals.size > 0:
white_p50 = float(np.percentile(white_vals, 50))
white_p05 = float(np.percentile(white_vals, 5))
white_p95 = float(np.percentile(white_vals, 95))
white_sat = float((white_vals >= 0.98).mean() * 100.0)
white_dark = float((white_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("white", {})[ch_name] = {
"p05": white_p05,
"p50": white_p50,
"p95": white_p95,
"sat_pct": white_sat,
"dark_pct": white_dark,
"roi_px": list(white_roi),
}
if white_sat > 0.5:
result["warnings"].append(f"{ch_name}: white_patch_saturated_{white_sat:.2f}%")
if white_p50 > 1e-6:
max_scale_by_white = white_guard_max / white_p50
if scale > max_scale_by_white:
result["warnings"].append(
f"{ch_name}: scale_limited_by_white_guard "
f"{scale:.3f}->{max_scale_by_white:.3f}"
)
scale = min(scale, max_scale_by_white)
# -----------------------------
# Stats do black, só diagnóstico
# -----------------------------
if black_roi is not None:
bx0, by0, bx1, by1 = black_roi
black_vals = ch[by0:by1, bx0:bx1].reshape(-1)
if black_vals.size > 0:
black_p50 = float(np.percentile(black_vals, 50))
black_p05 = float(np.percentile(black_vals, 5))
black_p95 = float(np.percentile(black_vals, 95))
black_sat = float((black_vals >= 0.98).mean() * 100.0)
black_dark = float((black_vals <= 0.02).mean() * 100.0)
result["patch_stats"].setdefault("black", {})[ch_name] = {
"p05": black_p05,
"p50": black_p50,
"p95": black_p95,
"sat_pct": black_sat,
"dark_pct": black_dark,
"roi_px": list(black_roi),
}
scale_before_clip = float(scale)
scale = float(np.clip(scale, scale_min, scale_max))
if abs(scale - scale_before_clip) > 1e-6:
result["warnings"].append(
f"{ch_name}: scale_clipped {scale_before_clip:.3f}->{scale:.3f}"
)
out[ci] = ch * scale
result["scales"][ch_name] = {
"scale": scale,
"gray_target": gray_target,
"gray_measured_p50": gray_p50,
}
if clip_output:
out = np.clip(out, 0.0, 1.0)
result["applied"] = True
result["valid"] = bool(len(result["scales"]) == len(channel_names))
result["clip_output"] = clip_output
result["shape"] = list(out.shape)
result["channel_names"] = channel_names
self.last_patch_normalization_result = result
return out.astype(np.float32, copy=False)
def _roi_pct_to_pixels_from_patch(self, roi_pct: dict, w: int, h: int):
x0 = int(float(roi_pct.get("x0", 0.0)) * w)
y0 = int(float(roi_pct.get("y0", 0.0)) * h)
x1 = int(float(roi_pct.get("x1", 1.0)) * w)
y1 = int(float(roi_pct.get("y1", 1.0)) * h)
x0 = max(0, min(w - 1, x0))
x1 = max(x0 + 1, min(w, x1))
y0 = max(0, min(h - 1, y0))
y1 = max(y0 + 1, min(h, y1))
return x0, y0, x1, y1
def extract_camera_meta(self, meta_json: dict, cam_id: str) -> dict: def extract_camera_meta(self, meta_json: dict, cam_id: str) -> dict:
cam_frames = meta_json.get("camera_frames", {}) or meta_json.get("stream_meta", {}).get("camera_frames", {}) cam_frames = meta_json.get("camera_frames", {}) or meta_json.get("stream_meta", {}).get("camera_frames", {})
@ -778,7 +997,6 @@ class RawProcessorCore:
return arr.reshape(shape) return arr.reshape(shape)
def save_rgb_u8_file(self, path: str, arr: np.ndarray): def save_rgb_u8_file(self, path: str, arr: np.ndarray):
arr.astype(np.uint8).tofile(path) arr.astype(np.uint8).tofile(path)
@ -830,7 +1048,7 @@ class RawProcessorCore:
) )
def load_fusion_config_json(self, path: str): def load_config_json(self, path: str):
if not path or not os.path.isfile(path): if not path or not os.path.isfile(path):
raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}") raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}")
@ -839,57 +1057,433 @@ class RawProcessorCore:
fusion = data.get("fusion_config") fusion = data.get("fusion_config")
if isinstance(fusion, dict): if isinstance(fusion, dict):
self.fusion_config = self._merge_fusion_config(self.fusion_config, fusion) self.fusion_config = self._merge_config(self.fusion_config, fusion)
else: else:
print("[WARN] JSON sem fusion_config. Mantendo config padrão.") print("[WARN] JSON sem fusion_config. Mantendo config padrão.")
rgb_cal = data.get("rgb_calibration") rgb_cal = data.get("rgb_calibration")
if isinstance(rgb_cal, dict): if isinstance(rgb_cal, dict):
self.rgb_calibration = self._merge_fusion_config(self.rgb_calibration, rgb_cal) self.rgb_calibration = self._merge_config(self.rgb_calibration, rgb_cal)
def _merge_fusion_config(self, default_cfg: dict, loaded_cfg: dict) -> dict: flatfield = data.get("flatfield_config")
if isinstance(flatfield, dict):
self.flatfield_config = self._merge_config(self.flatfield_config, flatfield)
self.load_flatfield_maps()
else:
self.flatfield_config["enabled"] = False
self.flatfield_maps = {}
self.flatfield_loaded = False
radiometric = data.get("radiometric_config")
if isinstance(radiometric, dict):
self.radiometric_config = self._merge_config(self.radiometric_config, radiometric)
rad_norm_config = data.get("radiometric_normalization")
if isinstance(rad_norm_config, dict):
self.radiometric_normalization_config = self._merge_config(self.radiometric_normalization_config, rad_norm_config)
patch_norm = data.get("patch_normalization")
if isinstance(patch_norm, dict):
self.patch_normalization_config = self._merge_config(self.patch_normalization_config, patch_norm)
cam_set = data.get("camera_settings")
if isinstance(cam_set, dict):
self.camera_settings = self._merge_config(self.camera_settings, cam_set)
def _merge_config(self, default_cfg: dict, loaded_cfg: dict) -> dict:
cfg = json.loads(json.dumps(default_cfg)) cfg = json.loads(json.dumps(default_cfg))
for key, value in loaded_cfg.items(): def merge(dst: dict, src: dict):
if isinstance(value, dict) and isinstance(cfg.get(key), dict): for key, value in src.items():
cfg[key].update(value) if isinstance(value, dict) and isinstance(dst.get(key), dict):
else: merge(dst[key], value)
cfg[key] = value else:
dst[key] = value
if isinstance(loaded_cfg, dict):
merge(cfg, loaded_cfg)
return cfg return cfg
def _decode_spectral_frame_to_float01(self, data, cam_meta):
arr = data
if arr.ndim == 3 and arr.shape[2] == 1: def _resolve_calibration_path(self, path: str) -> str:
arr = arr[:, :, 0] if not path:
return ""
bit_depth = int(cam_meta.get("bit_depth", 8)) path = str(path).replace("\\", "/")
channels = int(cam_meta.get("channels", 1)) if cam_meta.get("channels") is not None else 1
# Caso preview/processado: mono já vem uint8/uint16 normal. if os.path.isabs(path):
if arr.ndim == 2 and arr.dtype == np.uint8: return path
return np.clip(arr.astype(np.float32) / 255.0, 0.0, 1.0)
if arr.ndim == 2 and arr.dtype == np.uint16 and bit_depth != 10: # Primeiro tenta relativo ao diretório de execução.
return np.clip(arr.astype(np.float32) / 65535.0, 0.0, 1.0) if os.path.isfile(path):
return path
if bit_depth == 10: # Depois tenta relativo ao diretório do module_params.json.
sensor_width = int(cam_meta.get("width", self.sensor_width)) candidate = os.path.join(self.calibration_base_dir, path)
sensor_height = int(cam_meta.get("height", arr.shape[0])) if os.path.isfile(candidate):
return candidate
raw16 = self.unpack_raw10_packed( # Por fim, se o path já começa com "calibration/", tenta relativo ao pai da pasta calibration.
arr, base_parent = os.path.dirname(self.calibration_base_dir)
sensor_width=sensor_width, candidate = os.path.join(base_parent, path)
sensor_height=sensor_height, if os.path.isfile(candidate):
return candidate
return path
def load_flatfield_maps(self):
cfg = self.flatfield_config or {}
if not cfg.get("enabled", False):
self.flatfield_maps = {}
self.flatfield_loaded = False
return False
npz_file = cfg.get("npz_file")
if not npz_file:
print("[WARN] flatfield_config habilitado, mas sem npz_file.")
self.flatfield_maps = {}
self.flatfield_loaded = False
return False
npz_path = self._resolve_calibration_path(npz_file)
if not os.path.isfile(npz_path):
print(f"[WARN] Arquivo flat-field não encontrado: {npz_file} -> {npz_path}")
self.flatfield_maps = {}
self.flatfield_loaded = False
return False
data = np.load(npz_path)
maps = {}
channel_maps = cfg.get("channel_maps", {}) or {}
channels = cfg.get("channels", ["R", "G", "B", "RE", "NIR"])
for ch in channels:
ch = str(ch).upper()
ch_cfg = channel_maps.get(ch, {}) or {}
gain_key = ch_cfg.get("gain_key", f"gain_{ch}")
dark_key = ch_cfg.get("dark_median_key", f"dark_median_{ch}")
if gain_key not in data:
print(f"[WARN] Flat-field sem chave {gain_key} para canal {ch}.")
continue
entry = {
"gain": data[gain_key].astype(np.float32),
"gain_key": gain_key,
}
if dark_key and dark_key in data:
entry["dark"] = data[dark_key].astype(np.float32)
entry["dark_key"] = dark_key
maps[ch] = entry
self.flatfield_maps = maps
self.flatfield_loaded = len(maps) > 0
if self.flatfield_loaded:
print(f"[OK] Flat-field carregado: {npz_path} | canais={list(maps.keys())}")
else:
print(f"[WARN] Flat-field habilitado, mas nenhum mapa foi carregado: {npz_path}")
return self.flatfield_loaded
def apply_dark_to_decoded(self, decoded: dict) -> dict:
cfg = self.flatfield_config or {}
if not cfg.get("enabled", False):
return decoded
subtract_dark = bool(cfg.get("subtract_dark", True))
if not subtract_dark:
return decoded
if not self.flatfield_loaded:
self.load_flatfield_maps()
if not self.flatfield_loaded:
return decoded
corrected = {}
for cam_id, item in decoded.items():
role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
img = item.get("image")
if img is None:
corrected[cam_id] = item
continue
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
if role == "rgb":
if img.ndim != 3 or img.shape[2] < 3:
corrected[cam_id] = item
continue
out = img.astype(np.float32).copy()
for idx, ch in enumerate(("R", "G", "B")):
out[:, :, idx] = self._subtract_dark_single_channel(
out[:, :, idx],
ch,
)
new_item["image"] = out
elif role in ("re", "nir"):
ch = "RE" if role == "re" else "NIR"
new_item["image"] = self._subtract_dark_single_channel(
img.astype(np.float32),
ch,
)
else:
corrected[cam_id] = item
continue
new_meta["dark_applied"] = True
new_item["meta"] = new_meta
corrected[cam_id] = new_item
return corrected
def apply_flat_gain_to_decoded(self, decoded: dict) -> dict:
cfg = self.flatfield_config or {}
if not cfg.get("enabled", False):
return decoded
if not self.flatfield_loaded:
self.load_flatfield_maps()
if not self.flatfield_loaded:
return decoded
clip_output = bool(cfg.get("clip_output", True))
corrected = {}
for cam_id, item in decoded.items():
role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
img = item.get("image")
if img is None:
corrected[cam_id] = item
continue
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
if role == "rgb":
if img.ndim != 3 or img.shape[2] < 3:
corrected[cam_id] = item
continue
out = img.astype(np.float32).copy()
for idx, ch in enumerate(("R", "G", "B")):
out[:, :, idx] = self._apply_flat_gain_single_channel(
out[:, :, idx],
ch,
clip_output=clip_output,
)
new_item["image"] = out
elif role in ("re", "nir"):
ch = "RE" if role == "re" else "NIR"
new_item["image"] = self._apply_flat_gain_single_channel(
img.astype(np.float32),
ch,
clip_output=clip_output,
)
else:
corrected[cam_id] = item
continue
new_meta["flatfield_applied"] = True
new_meta["flatfield_map_type"] = cfg.get("map_type", "gain")
new_item["meta"] = new_meta
corrected[cam_id] = new_item
return corrected
def _subtract_dark_single_channel(
self,
img: np.ndarray,
channel_name: str,
) -> np.ndarray:
ch = str(channel_name).upper()
entry = self.flatfield_maps.get(ch)
if not entry:
return img.astype(np.float32, copy=False)
dark = entry.get("dark")
if dark is None:
return img.astype(np.float32, copy=False)
base = img.astype(np.float32)
dark = dark.astype(np.float32)
if dark.shape[:2] != base.shape[:2]:
dark = cv2.resize(
dark,
(base.shape[1], base.shape[0]),
interpolation=cv2.INTER_LINEAR,
) )
max_val = float((1 << bit_depth) - 1) out = np.maximum(base - dark, 0.0)
return np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0) return out.astype(np.float32, copy=False)
# fallback def _apply_flat_gain_single_channel(
arr01 = arr.astype(np.float32) self,
if arr01.max() > 1.5: img: np.ndarray,
arr01 /= 255.0 channel_name: str,
clip_output: bool = True,
) -> np.ndarray:
ch = str(channel_name).upper()
entry = self.flatfield_maps.get(ch)
return np.clip(arr01, 0.0, 1.0) if not entry:
return img.astype(np.float32, copy=False)
gain = entry.get("gain")
if gain is None:
return img.astype(np.float32, copy=False)
base = img.astype(np.float32)
gain = gain.astype(np.float32)
if gain.shape[:2] != base.shape[:2]:
gain = cv2.resize(
gain,
(base.shape[1], base.shape[0]),
interpolation=cv2.INTER_LINEAR,
)
out = base * gain
if clip_output:
out = np.clip(out, 0.0, 1.0)
return out.astype(np.float32, copy=False)
def normalize_decoded_by_capture_controls(self, decoded: dict, meta: dict | None = None) -> dict:
cfg = self.radiometric_normalization_config or {}
if not cfg.get("enabled", False):
return decoded
method = str(cfg.get("method", "exposure_gain_reference")).lower()
if method != "exposure_gain_reference":
return decoded
controls = self._extract_actual_controls_from_meta(meta)
if not controls:
return decoded
reference_controls = cfg.get("reference_controls", {}) or {}
clip_output = bool(cfg.get("clip_output", False))
normalized = {}
for cam_id, item in decoded.items():
role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
img = item.get("image")
if img is None or not role:
normalized[cam_id] = item
continue
actual_ctrl = controls.get(role, {}) or {}
ref_ctrl = (
reference_controls.get(role)
or self.camera_settings.get(role)
or actual_ctrl
or {}
)
actual_factor = self._exposure_gain_factor(actual_ctrl)
ref_factor = self._exposure_gain_factor(ref_ctrl)
if actual_factor <= 0 or ref_factor <= 0:
normalized[cam_id] = item
continue
scale = ref_factor / actual_factor
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
out = img.astype(np.float32) * float(scale)
if clip_output:
out = np.clip(out, 0.0, 1.0)
new_meta["radiometric_normalization_applied"] = True
new_meta["radiometric_normalization_method"] = method
new_meta["radiometric_normalization_scale"] = float(scale)
new_meta["radiometric_actual_factor"] = float(actual_factor)
new_meta["radiometric_reference_factor"] = float(ref_factor)
new_item["image"] = out.astype(np.float32, copy=False)
new_item["meta"] = new_meta
normalized[cam_id] = new_item
return normalized
def _extract_actual_controls_from_meta(self, meta: dict | None) -> dict:
if not meta:
return {}
for key in ("actual_camera_controls", "camera_controls", "startup_camera_controls"):
controls = meta.get(key)
if isinstance(controls, dict) and controls:
return controls
stream_meta = meta.get("stream_meta")
if isinstance(stream_meta, dict):
for key in ("actual_camera_controls", "camera_controls", "startup_camera_controls"):
controls = stream_meta.get(key)
if isinstance(controls, dict) and controls:
return controls
return {}
def _exposure_gain_factor(self, ctrl: dict) -> float:
if not isinstance(ctrl, dict):
return 0.0
exp = ctrl.get("exposure_time_us", None)
gain = ctrl.get("analogue_gain", None)
try:
exp = float(exp)
except Exception:
exp = 0.0
try:
gain = float(gain)
except Exception:
gain = 1.0
if exp <= 0:
return 0.0
if gain <= 0:
gain = 1.0
return float(exp * gain)

View File

@ -245,7 +245,7 @@ def main():
if not isinstance(radiometric_normalization_config, dict): if not isinstance(radiometric_normalization_config, dict):
radiometric_normalization_config = { radiometric_normalization_config = {
"enabled": True, "enabled": False,
"method": "exposure_gain_reference", "method": "exposure_gain_reference",
"apply_stage": "after_dark_before_flat_gain", "apply_stage": "after_dark_before_flat_gain",
"reference_controls": { "reference_controls": {
@ -255,6 +255,10 @@ def main():
}, },
"clip_output": True, "clip_output": True,
} }
else:
radiometric_normalization_config["enabled"] = bool(
radiometric_normalization_config.get("enabled", False)
)
patch_normalization_config = radiometric_data.get("patch_normalization") patch_normalization_config = radiometric_data.get("patch_normalization")
if not isinstance(patch_normalization_config, dict): if not isinstance(patch_normalization_config, dict):
@ -262,17 +266,30 @@ def main():
if not isinstance(patch_normalization_config, dict): if not isinstance(patch_normalization_config, dict):
patch_normalization_config = { patch_normalization_config = {
"enabled": False, "enabled": True,
"apply_when_metering_mode": "reference_patches", "apply_when_metering_mode": "reference_patches",
"apply_stage": "after_fusion", "apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard", "method": "gray_scale_with_white_guard",
"space": "multispec_tensor", "space": "multispec_tensor",
"targets": { "targets_by_patch_channel": {
"black": 0.06, "black": {
"gray": 0.40, "R": 0.06, "G": 0.06, "B": 0.06, "RE": 0.06, "NIR": 0.06
"white": 0.78, },
"gray": {
"R": 0.34, "G": 0.34, "B": 0.34, "RE": 0.24, "NIR": 0.30
},
"white": {
"R": 0.78, "G": 0.78, "B": 0.78, "RE": 0.78, "NIR": 0.78
},
}, },
"white_guard_max": 0.92, "white_guard_max": 0.92,
"white_guard_max_by_channel": {
"R": 0.92,
"G": 0.92,
"B": 0.92,
"RE": 0.88,
"NIR": 0.88,
},
"scale_min": 0.35, "scale_min": 0.35,
"scale_max": 2.50, "scale_max": 2.50,
"clip_output": True, "clip_output": True,

View File

@ -43,7 +43,7 @@ def save_multispec_tensor_from_raw_group(
ensure_dir(out_dir) ensure_dir(out_dir)
out_dir = Path(out_dir) out_dir = Path(out_dir)
tensor, desc = build_multispec_from_raw_native_multi(group, meta) tensor, desc, processing_info = build_multispec_from_raw_native_multi(group, meta)
if tensor is None: if tensor is None:
raise RuntimeError(f"Falha ao gerar tensor MULTISPEC: {desc}") raise RuntimeError(f"Falha ao gerar tensor MULTISPEC: {desc}")
@ -85,6 +85,9 @@ def save_multispec_tensor_from_raw_group(
"description": desc, "description": desc,
"same_frame_as_raw_bruto": True, "same_frame_as_raw_bruto": True,
}, },
"processing": processing_info or {},
"frame_quality": (processing_info or {}).get("frame_quality"),
"patch_normalization_result": (processing_info or {}).get("patch_normalization_result"),
"source_capture_meta": { "source_capture_meta": {
"ts": meta.get("ts"), "ts": meta.get("ts"),
"sensor_width": meta.get("sensor_width"), "sensor_width": meta.get("sensor_width"),
@ -124,9 +127,44 @@ def chw_to_hwc(arr: np.ndarray) -> np.ndarray:
return np.transpose(arr, (1, 2, 0)) return np.transpose(arr, (1, 2, 0))
def tensor_to_preview_panels(tensor: np.ndarray): def format_frame_quality_for_overlay(frame_quality: dict | None, patch_result: dict | None = None) -> str:
"""
Gera uma linha curta para mostrar no preview do tensor final.
Exemplo:
Q=good | sat=0.00% | dark=12.3% | clip=0.00% | scale=0.91-1.08
"""
if not isinstance(frame_quality, dict):
return "Q=n/a"
status = frame_quality.get("status", "unknown")
metrics = frame_quality.get("metrics", {}) or {}
sat = float(metrics.get("max_tensor_sat_pct", 0.0) or 0.0)
dark = float(metrics.get("max_tensor_dark_pct", 0.0) or 0.0)
clip = float(metrics.get("max_patch_would_clip_pct", 0.0) or 0.0)
scale_txt = "scale=n/a"
if isinstance(patch_result, dict):
summary = patch_result.get("summary", {}) or {}
smin = summary.get("scale_min_applied")
smax = summary.get("scale_max_applied")
if smin is not None and smax is not None:
try:
scale_txt = f"scale={float(smin):.2f}-{float(smax):.2f}"
except Exception:
pass
reasons = frame_quality.get("reasons", []) or []
reason_txt = ""
if status != "good" and reasons:
reason_txt = f" | {str(reasons[0])[:38]}"
return f"Q={status} | sat={sat:.2f}% | dark={dark:.1f}% | clip={clip:.2f}% | {scale_txt}{reason_txt}"
def tensor_to_preview_panels(tensor: np.ndarray, frame_quality: dict | None = None, patch_result: dict | None = None):
""" """
Recebe tensor CHW [R,G,B,RE,NIR] float32 e devolve painéis visuais. Recebe tensor CHW [R,G,B,RE,NIR] float32 e devolve painéis visuais.
Quando disponível, adiciona um resumo de qualidade no subtítulo do painel RGB final.
""" """
if tensor.ndim != 3 or tensor.shape[0] < 5: if tensor.ndim != 3 or tensor.shape[0] < 5:
raise RuntimeError(f"Tensor MULTISPEC inválido: shape={tensor.shape}") raise RuntimeError(f"Tensor MULTISPEC inválido: shape={tensor.shape}")
@ -147,8 +185,13 @@ def tensor_to_preview_panels(tensor: np.ndarray):
cv2.COLOR_GRAY2BGR cv2.COLOR_GRAY2BGR
) )
quality_subtitle = format_frame_quality_for_overlay(frame_quality, patch_result)
rgb_subtitle = f"tensor {list(tensor.shape)} | canais 0,1,2"
if quality_subtitle:
rgb_subtitle = f"{rgb_subtitle} | {quality_subtitle}"
return [ return [
("MULTISPEC RGB final", rgb_bgr, f"tensor {list(tensor.shape)} | canais 0,1,2"), ("MULTISPEC RGB final", rgb_bgr, rgb_subtitle),
("MULTISPEC RE final", re_bgr, "tensor canal 3"), ("MULTISPEC RE final", re_bgr, "tensor canal 3"),
("MULTISPEC NIR final", nir_bgr, "tensor canal 4"), ("MULTISPEC NIR final", nir_bgr, "tensor canal 4"),
] ]
@ -166,7 +209,7 @@ def build_multispec_from_raw_native_multi(group: dict, meta: dict):
- camera_params_json/module_params.json - camera_params_json/module_params.json
""" """
if meta.get("saved_payload_type") != "raw_native_multi": if meta.get("saved_payload_type") != "raw_native_multi":
return None, "captura não é raw_native_multi" return None, "captura não é raw_native_multi", {}
stream_meta = meta.get("stream_meta", {}) or {} stream_meta = meta.get("stream_meta", {}) or {}
camera_info = stream_meta.get("camera_info", {}) or {} camera_info = stream_meta.get("camera_info", {}) or {}
@ -227,7 +270,12 @@ def build_multispec_from_raw_native_multi(group: dict, meta: dict):
tensor = core.build_infer_tensor_from_stream(frame, processing_meta, 5) tensor = core.build_infer_tensor_from_stream(frame, processing_meta, 5)
return tensor, f"MULTISPEC gerado offline do RAW_BRUTO | shape={list(tensor.shape)}" processing_info = {
"patch_normalization_result": getattr(core, "last_patch_normalization_result", None),
"frame_quality": getattr(core, "last_frame_quality_result", None),
}
return tensor, f"MULTISPEC gerado offline do RAW_BRUTO | shape={list(tensor.shape)}", processing_info
def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str | None = None) -> tuple[np.ndarray, str]: def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str | None = None) -> tuple[np.ndarray, str]:
@ -333,30 +381,26 @@ def build_visual_from_saved_payload(payload_path: Path, meta: dict, cam_id: str
if arr.ndim != 3 or arr.shape[0] < 5: if arr.ndim != 3 or arr.shape[0] < 5:
raise RuntimeError(f"Payload MULTISPEC inválido, shape={arr.shape}") raise RuntimeError(f"Payload MULTISPEC inválido, shape={arr.shape}")
rgb_hwc = chw_to_hwc(arr[:3].astype(np.float32)) processing = meta.get("processing", {}) or {}
rgb_bgr = normalize_float01_to_bgr(rgb_hwc) frame_quality = meta.get("frame_quality") or processing.get("frame_quality")
patch_result = meta.get("patch_normalization_result") or processing.get("patch_normalization_result")
re01 = arr[3].astype(np.float32) panels = tensor_to_preview_panels(
nir01 = arr[4].astype(np.float32) arr.astype(np.float32),
frame_quality=frame_quality,
re_bgr = cv2.cvtColor( patch_result=patch_result,
np.clip(re01 * 255.0, 0, 255).astype(np.uint8),
cv2.COLOR_GRAY2BGR
) )
nir_bgr = cv2.cvtColor( # Renomeia os painéis para indicar que vieram de um MULTISPEC já salvo.
np.clip(nir01 * 255.0, 0, 255).astype(np.uint8), renamed = []
cv2.COLOR_GRAY2BGR for title, img, subtitle in panels:
) title = title.replace("MULTISPEC RGB final", "RGB reconstruido")
title = title.replace("MULTISPEC RE final", "RE reconstruido")
panels = [ title = title.replace("MULTISPEC NIR final", "NIR reconstruido")
("RGB reconstruido", rgb_bgr, "canais 0,1,2"), renamed.append((title, img, subtitle))
("RE reconstruido", re_bgr, "canal 3"),
("NIR reconstruido", nir_bgr, "canal 4"),
]
desc = f"Reconstruido de MULTISPEC | dtype={arr.dtype} | shape={arr.shape} | canais=[R,G,B,RE,NIR]" desc = f"Reconstruido de MULTISPEC | dtype={arr.dtype} | shape={arr.shape} | canais=[R,G,B,RE,NIR]"
return panels, desc return renamed, desc
if saved_type == "raw_native_single": if saved_type == "raw_native_single":
if arr.ndim == 3 and arr.shape[2] == 3 and arr.dtype == np.uint8: if arr.ndim == 3 and arr.shape[2] == 3 and arr.dtype == np.uint8:
@ -445,13 +489,19 @@ def build_panels_from_group(group):
# ========================================================= # =========================================================
if saved_type == "raw_native_multi": if saved_type == "raw_native_multi":
try: try:
tensor, desc = build_multispec_from_raw_native_multi(group, meta) tensor, desc, processing_info = build_multispec_from_raw_native_multi(group, meta)
tensor_panels = tensor_to_preview_panels(tensor) tensor_panels = tensor_to_preview_panels(
tensor,
frame_quality=(processing_info or {}).get("frame_quality"),
patch_result=(processing_info or {}).get("patch_normalization_result"),
)
# Aqui colocamos só o RGB final como painel principal, # Aqui colocamos só o RGB final como painel principal,
# para substituir o antigo PNG salvo. # para substituir o antigo PNG salvo.
title, img, subtitle = tensor_panels[0] title, img, subtitle = tensor_panels[0]
panels.append((title, img, desc)) # Mostra a qualidade do tensor gerado offline a partir do RAW_BRUTO.
# O desc completo continua sendo impresso no terminal/salvo no JSON offline.
panels.append((title, img, subtitle))
# Opcional: se quiser também ver RE/NIR finais do tensor, # Opcional: se quiser também ver RE/NIR finais do tensor,
# descomente estas duas linhas: # descomente estas duas linhas:
@ -598,8 +648,24 @@ def put_label(img: np.ndarray, title: str, subtitle: str = "") -> np.ndarray:
cv2.putText(out, title, (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2, cv2.LINE_AA) cv2.putText(out, title, (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2, cv2.LINE_AA)
if subtitle: if subtitle:
cv2.putText(out, subtitle, (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 3, cv2.LINE_AA) # Quebra visual simples para linhas longas de debug/qualidade.
cv2.putText(out, subtitle, (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA) subtitle_lines = []
current = ""
for part in str(subtitle).split(" | "):
candidate = part if not current else current + " | " + part
if len(candidate) > 95 and current:
subtitle_lines.append(current)
current = part
else:
current = candidate
if current:
subtitle_lines.append(current)
y = 56
for line in subtitle_lines[:3]:
cv2.putText(out, line, (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 3, cv2.LINE_AA)
cv2.putText(out, line, (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA)
y += 22
return out return out

File diff suppressed because it is too large Load Diff

View File

@ -222,26 +222,26 @@ def base_ae_contract():
"verbose": True, "verbose": True,
"control_metric": "p50", "control_metric": "p50",
"target_value": 0.40, "target_value": 0.34,
"deadband": 0.04, "deadband": 0.035,
"p95_limit": 0.90, "p95_limit": 0.94,
"saturation_limit_pct": 0.50, "saturation_limit_pct": 0.50,
"dark_limit_pct": 35.0, "dark_limit_pct": 35.0,
# Novo controle proporcional por razão # Novo controle proporcional por razão
"control_strategy": "ratio", "control_strategy": "ratio",
"ratio_alpha": 0.55, "ratio_alpha": 0.35,
"ratio_min": 0.55, "ratio_min": 0.65,
"ratio_max": 1.85, "ratio_max": 1.35,
# Redução rápida quando satura # Redução rápida quando satura
"reduce_fast_factor": 0.75, "reduce_fast_factor": 0.80,
# Mantém compatibilidade com o modo antigo # Mantém compatibilidade com o modo antigo
"alpha": 0.18, "alpha": 0.18,
"exp_step_gain": 0.55, "exp_step_gain": 0.55,
"factor_min": 0.72, "factor_min": 0.55,
"factor_max": 1.28, "factor_max": 1.28,
"prefer_exposure": True, "prefer_exposure": True,
@ -263,12 +263,12 @@ def base_ae_contract():
"role_limits": { "role_limits": {
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0}, "rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0},
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0}, "re": {"exp_min_us": 100, "exp_max_us": 2500, "gain_min": 1.0, "gain_max": 2.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0}, "nir": {"exp_min_us": 100, "exp_max_us": 3000, "gain_min": 1.0, "gain_max": 2.0},
}, },
"exp_apply_threshold_us": 40, "exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.03, "gain_apply_threshold": 0.05,
"ready_required_cycles": 3, "ready_required_cycles": 3,
@ -322,6 +322,12 @@ def make_default_patch(patch_type: str, target: float, weight: float):
def default_profile_patches(): def default_profile_patches():
cfg = base_ae_contract() cfg = base_ae_contract()
gray_patch = make_default_patch("gray", 0.34, 1.0)
gray_patch["target_value_by_role"] = {
"rgb": 0.34,
"re": 0.24,
"nir": 0.30,
}
cfg.update({ cfg.update({
"metering_mode": "reference_patches", "metering_mode": "reference_patches",
"spectral_control_mode": "shared", "spectral_control_mode": "shared",
@ -348,7 +354,7 @@ def default_profile_patches():
"reference_patches": [ "reference_patches": [
make_default_patch("black", 0.06, 0.25), make_default_patch("black", 0.06, 0.25),
make_default_patch("gray", 0.40, 1.0), gray_patch,
make_default_patch("white", 0.78, 0.7), make_default_patch("white", 0.78, 0.7),
], ],
}) })
@ -399,12 +405,37 @@ def load_or_default_config(path: str):
"apply_stage": "after_fusion", "apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard", "method": "gray_scale_with_white_guard",
"space": "multispec_tensor", "space": "multispec_tensor",
"targets": { "targets_by_patch_channel": {
"black": 0.06, "black": {
"gray": 0.40, "R": 0.06,
"white": 0.78 "G": 0.06,
"B": 0.06,
"RE": 0.06,
"NIR": 0.06
},
"gray": {
"R": 0.34,
"G": 0.34,
"B": 0.34,
"RE": 0.24,
"NIR": 0.30
},
"white": {
"R": 0.78,
"G": 0.78,
"B": 0.78,
"RE": 0.78,
"NIR": 0.78
}
}, },
"white_guard_max": 0.92, "white_guard_max": 0.92,
"white_guard_max_by_channel": {
"R": 0.92,
"G": 0.92,
"B": 0.92,
"RE": 0.88,
"NIR": 0.88
},
"scale_min": 0.35, "scale_min": 0.35,
"scale_max": 2.50, "scale_max": 2.50,
"clip_output": True, "clip_output": True,
@ -1348,12 +1379,37 @@ def main():
"apply_stage": "after_fusion", "apply_stage": "after_fusion",
"method": "gray_scale_with_white_guard", "method": "gray_scale_with_white_guard",
"space": "multispec_tensor", "space": "multispec_tensor",
"targets": { "targets_by_patch_channel": {
"black": 0.06, "black": {
"gray": 0.40, "R": 0.06,
"white": 0.78 "G": 0.06,
"B": 0.06,
"RE": 0.06,
"NIR": 0.06
},
"gray": {
"R": 0.34,
"G": 0.34,
"B": 0.34,
"RE": 0.24,
"NIR": 0.30
},
"white": {
"R": 0.78,
"G": 0.78,
"B": 0.78,
"RE": 0.78,
"NIR": 0.78
}
}, },
"white_guard_max": 0.92, "white_guard_max": 0.92,
"white_guard_max_by_channel": {
"R": 0.92,
"G": 0.92,
"B": 0.92,
"RE": 0.88,
"NIR": 0.88
},
"scale_min": 0.35, "scale_min": 0.35,
"scale_max": 2.50, "scale_max": 2.50,
"clip_output": True, "clip_output": True,