diff --git a/Python/OAK/datasets/oak-fcc-3/_0_capture.py b/Python/OAK/datasets/oak-fcc-3/_0_capture.py index c6c984b4a..9d3a0b32f 100644 --- a/Python/OAK/datasets/oak-fcc-3/_0_capture.py +++ b/Python/OAK/datasets/oak-fcc-3/_0_capture.py @@ -407,7 +407,6 @@ def main(): "camera_params_json": args.module_calibration_json, "note": "autosave", "raw_preview_reference_camera": preview_source_id, - "patch_normalization_result": cam.get_last_patch_normalization_result() } save_sample( @@ -470,7 +469,6 @@ def main(): "camera_params_json": args.module_calibration_json, "note": "manual", "raw_preview_reference_camera": preview_source_id, - "patch_normalization_result": cam.get_last_patch_normalization_result() } save_sample( diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/manual_offsets.json b/Python/OAK/datasets/oak-fcc-3/calibration/manual_offsets.json index 1d4caa003..3e65c0912 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/manual_offsets.json +++ b/Python/OAK/datasets/oak-fcc-3/calibration/manual_offsets.json @@ -1,6 +1,6 @@ { "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", "capture_mode_requested": "AUTO", "capture_mode_effective": "AUTO", @@ -26,35 +26,35 @@ "homographies": { "re_to_rgb": [ [ - 0.9836077983170345, - -0.00414576215755023, - -7.995670153079967 + 0.9891488988352489, + -0.0057973762346762115, + -15.173511767385348 ], [ - -0.009155951570825544, - 0.9872093474367271, - 65.61737450021248 + -0.015114489970267686, + 0.9903282034674085, + 58.93120393758602 ], [ - -8.515127189657688e-06, - -3.4530877754475106e-06, + -2.1528138783460464e-05, + -1.5606269505459822e-05, 1.0 ] ], "nir_to_rgb": [ [ - 0.9950840771095427, - -0.01203311522937068, - -0.37678489564225515 + 1.015255211995985, + -0.011641307565439515, + -15.440297021288165 ], [ - 0.005818513521498549, - 0.9844634830519817, - 40.58679770900771 + 0.006224807373165827, + 1.0059040970450264, + 21.05815660535078 ], [ - 1.1689856045746963e-05, - -1.643688958474221e-05, + 5.830335800966281e-06, + -1.5297165752796802e-05, 1.0 ] ] diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json b/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json index cb6dd6128..387dc6f01 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json +++ b/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json @@ -1,6 +1,6 @@ { "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", "capture_mode_requested": "AUTO", "capture_mode_effective": "AUTO", @@ -53,35 +53,35 @@ "homographies": { "re_to_rgb": [ [ - 0.9836077983170345, - -0.00414576215755023, - -7.995670153079967 + 0.9891488988352489, + -0.0057973762346762115, + -15.173511767385348 ], [ - -0.009155951570825544, - 0.9872093474367271, - 65.61737450021248 + -0.015114489970267686, + 0.9903282034674085, + 58.93120393758602 ], [ - -8.515127189657688e-06, - -3.4530877754475106e-06, + -2.1528138783460464e-05, + -1.5606269505459822e-05, 1.0 ] ], "nir_to_rgb": [ [ - 0.9950840771095427, - -0.01203311522937068, - -0.37678489564225515 + 1.015255211995985, + -0.011641307565439515, + -15.440297021288165 ], [ - 0.005818513521498549, - 0.9844634830519817, - 40.58679770900771 + 0.006224807373165827, + 1.0059040970450264, + 21.05815660535078 ], [ - 1.1689856045746963e-05, - -1.643688958474221e-05, + 5.830335800966281e-06, + -1.5297165752796802e-05, 1.0 ] ] @@ -92,7 +92,7 @@ }, "radiometric_config": { "enabled": true, - "interval_s": 0.5, + "interval_s": 0.25, "verbose": true, "metering_mode": "reference_patches", "spectral_control_mode": "shared", @@ -118,31 +118,31 @@ "nir" ], "roi_pct": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "target_value": 0.08, "weight": 0.7, "roi_pct_by_role": { "rgb": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "re": { - "x0": 0.403125, + "x0": 0.395313, "y0": 0.745, - "x1": 0.459375, - "y1": 0.92 + "x1": 0.4625, + "y1": 0.9225 }, "nir": { - "x0": 0.364063, - "y0": 0.775, - "x1": 0.428125, - "y1": 0.9425 + "x0": 0.353125, + "y0": 0.78, + "x1": 0.420312, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -151,13 +151,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "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": [ @@ -165,13 +165,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.403125, + "x0": 0.395313, "y0": 0.745, - "x1": 0.459375, - "y1": 0.92 + "x1": 0.4625, + "y1": 0.9225 }, "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": [ @@ -179,13 +179,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.364063, - "y0": 0.775, - "x1": 0.428125, - "y1": 0.9425 + "x0": 0.353125, + "y0": 0.78, + "x1": 0.420312, + "y1": 0.9525 }, "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" ], "roi_pct": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, - "target_value": 0.5, + "target_value": 0.35, "target_value_by_role": { "rgb": 0.34, "re": 0.24, @@ -213,22 +213,22 @@ "weight": 1.0, "roi_pct_by_role": { "rgb": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "re": { - "x0": 0.332813, - "y0": 0.745, - "x1": 0.395313, + "x0": 0.325, + "y0": 0.75, + "x1": 0.389062, "y1": 0.915 }, "nir": { - "x0": 0.29375, - "y0": 0.775, - "x1": 0.35625, - "y1": 0.94 + "x0": 0.284375, + "y0": 0.79, + "x1": 0.35, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -237,13 +237,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "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": [ @@ -251,13 +251,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.332813, - "y0": 0.745, - "x1": 0.395313, + "x0": 0.325, + "y0": 0.75, + "x1": 0.389062, "y1": 0.915 }, "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": [ @@ -265,13 +265,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.29375, - "y0": 0.775, - "x1": 0.35625, - "y1": 0.94 + "x0": 0.284375, + "y0": 0.79, + "x1": 0.35, + "y1": 0.9525 }, "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" ], "roi_pct": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "target_value": 0.82, "weight": 0.8, "roi_pct_by_role": { "rgb": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "re": { - "x0": 0.2625, + "x0": 0.25, "y0": 0.7525, - "x1": 0.321875, + "x1": 0.315625, "y1": 0.9225 }, "nir": { - "x0": 0.220312, - "y0": 0.775, - "x1": 0.284375, - "y1": 0.9425 + "x0": 0.214062, + "y0": 0.785, + "x1": 0.282813, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -318,13 +318,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "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": [ @@ -332,13 +332,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.2625, + "x0": 0.25, "y0": 0.7525, - "x1": 0.321875, + "x1": 0.315625, "y1": 0.9225 }, "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": [ @@ -346,13 +346,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.220312, - "y0": 0.775, - "x1": 0.284375, - "y1": 0.9425 + "x0": 0.214062, + "y0": 0.785, + "x1": 0.282813, + "y1": 0.9525 }, "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 }, "radiometric_normalization": { - "enabled": true, + "enabled": false, "method": "exposure_gain_reference", "apply_stage": "after_dark_before_flat_gain", "reference_controls": { @@ -465,7 +465,7 @@ } }, "flatfield_config": { - "enabled": true, + "enabled": false, "subtract_dark": true, "schema": "multispec_flatfield_v1", "created_at": "2026-05-06 13:37:25", diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/radiometric_config.json b/Python/OAK/datasets/oak-fcc-3/calibration/radiometric_config.json index 42fc9f3d0..f0a0160f6 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/radiometric_config.json +++ b/Python/OAK/datasets/oak-fcc-3/calibration/radiometric_config.json @@ -120,31 +120,31 @@ "nir" ], "roi_pct": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "target_value": 0.08, "weight": 0.7, "roi_pct_by_role": { "rgb": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "re": { - "x0": 0.403125, + "x0": 0.395313, "y0": 0.745, - "x1": 0.459375, - "y1": 0.92 + "x1": 0.4625, + "y1": 0.9225 }, "nir": { - "x0": 0.364063, - "y0": 0.775, - "x1": 0.428125, - "y1": 0.9425 + "x0": 0.353125, + "y0": 0.78, + "x1": 0.420312, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -153,13 +153,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "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": [ @@ -167,13 +167,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.403125, + "x0": 0.395313, "y0": 0.745, - "x1": 0.459375, - "y1": 0.92 + "x1": 0.4625, + "y1": 0.9225 }, "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": [ @@ -181,13 +181,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.364063, - "y0": 0.775, - "x1": 0.428125, - "y1": 0.9425 + "x0": 0.353125, + "y0": 0.78, + "x1": 0.420312, + "y1": 0.9525 }, "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" ], "roi_pct": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "target_value": 0.5, "weight": 1.0, "roi_pct_by_role": { "rgb": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "re": { - "x0": 0.332813, - "y0": 0.745, - "x1": 0.395313, + "x0": 0.325, + "y0": 0.75, + "x1": 0.389062, "y1": 0.915 }, "nir": { - "x0": 0.29375, - "y0": 0.775, - "x1": 0.35625, - "y1": 0.94 + "x0": 0.284375, + "y0": 0.79, + "x1": 0.35, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -234,13 +234,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "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": [ @@ -248,13 +248,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.332813, - "y0": 0.745, - "x1": 0.395313, + "x0": 0.325, + "y0": 0.75, + "x1": 0.389062, "y1": 0.915 }, "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": [ @@ -262,13 +262,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.29375, - "y0": 0.775, - "x1": 0.35625, - "y1": 0.94 + "x0": 0.284375, + "y0": 0.79, + "x1": 0.35, + "y1": 0.9525 }, "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" ], "roi_pct": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "target_value": 0.82, "weight": 0.8, "roi_pct_by_role": { "rgb": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "re": { - "x0": 0.2625, + "x0": 0.25, "y0": 0.7525, - "x1": 0.321875, + "x1": 0.315625, "y1": 0.9225 }, "nir": { - "x0": 0.220312, - "y0": 0.775, - "x1": 0.284375, - "y1": 0.9425 + "x0": 0.214062, + "y0": 0.785, + "x1": 0.282813, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -315,13 +315,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "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": [ @@ -329,13 +329,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.2625, + "x0": 0.25, "y0": 0.7525, - "x1": 0.321875, + "x1": 0.315625, "y1": 0.9225 }, "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": [ @@ -343,13 +343,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.220312, - "y0": 0.775, - "x1": 0.284375, - "y1": 0.9425 + "x0": 0.214062, + "y0": 0.785, + "x1": 0.282813, + "y1": 0.9525 }, "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", - "saved_at": "2026-05-07 14:40:58", + "saved_at": "2026-05-08 09:21:09", "active_profile": "three_reference_patches_mode", "patch_normalization": { "enabled": true, @@ -488,31 +488,31 @@ "nir" ], "roi_pct": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "target_value": 0.08, "weight": 0.7, "roi_pct_by_role": { "rgb": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "re": { - "x0": 0.403125, + "x0": 0.395313, "y0": 0.745, - "x1": 0.459375, - "y1": 0.92 + "x1": 0.4625, + "y1": 0.9225 }, "nir": { - "x0": 0.364063, - "y0": 0.775, - "x1": 0.428125, - "y1": 0.9425 + "x0": 0.353125, + "y0": 0.78, + "x1": 0.420312, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -521,13 +521,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.376563, - "y0": 0.81, - "x1": 0.440625, - "y1": 0.985 + "x0": 0.365625, + "y0": 0.8225, + "x1": 0.432812, + "y1": 0.995 }, "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": [ @@ -535,13 +535,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.403125, + "x0": 0.395313, "y0": 0.745, - "x1": 0.459375, - "y1": 0.92 + "x1": 0.4625, + "y1": 0.9225 }, "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": [ @@ -549,13 +549,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.364063, - "y0": 0.775, - "x1": 0.428125, - "y1": 0.9425 + "x0": 0.353125, + "y0": 0.78, + "x1": 0.420312, + "y1": 0.9525 }, "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" ], "roi_pct": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "target_value": 0.5, "weight": 1.0, "roi_pct_by_role": { "rgb": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "re": { - "x0": 0.332813, - "y0": 0.745, - "x1": 0.395313, + "x0": 0.325, + "y0": 0.75, + "x1": 0.389062, "y1": 0.915 }, "nir": { - "x0": 0.29375, - "y0": 0.775, - "x1": 0.35625, - "y1": 0.94 + "x0": 0.284375, + "y0": 0.79, + "x1": 0.35, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -602,13 +602,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.30625, - "y0": 0.8225, - "x1": 0.370312, - "y1": 0.985 + "x0": 0.29375, + "y0": 0.825, + "x1": 0.3625, + "y1": 0.995 }, "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": [ @@ -616,13 +616,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.332813, - "y0": 0.745, - "x1": 0.395313, + "x0": 0.325, + "y0": 0.75, + "x1": 0.389062, "y1": 0.915 }, "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": [ @@ -630,13 +630,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.29375, - "y0": 0.775, - "x1": 0.35625, - "y1": 0.94 + "x0": 0.284375, + "y0": 0.79, + "x1": 0.35, + "y1": 0.9525 }, "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" ], "roi_pct": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "target_value": 0.82, "weight": 0.8, "roi_pct_by_role": { "rgb": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "re": { - "x0": 0.2625, + "x0": 0.25, "y0": 0.7525, - "x1": 0.321875, + "x1": 0.315625, "y1": 0.9225 }, "nir": { - "x0": 0.220312, - "y0": 0.775, - "x1": 0.284375, - "y1": 0.9425 + "x0": 0.214062, + "y0": 0.785, + "x1": 0.282813, + "y1": 0.9525 } }, "roi_list_by_role": { @@ -683,13 +683,13 @@ "name": "rgb_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.232813, + "x0": 0.220312, "y0": 0.8225, - "x1": 0.3, - "y1": 0.99 + "x1": 0.2875, + "y1": 0.995 }, "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": [ @@ -697,13 +697,13 @@ "name": "re_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.2625, + "x0": 0.25, "y0": 0.7525, - "x1": 0.321875, + "x1": 0.315625, "y1": 0.9225 }, "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": [ @@ -711,13 +711,13 @@ "name": "nir_legacy_01", "enabled": true, "roi_pct": { - "x0": 0.220312, - "y0": 0.775, - "x1": 0.284375, - "y1": 0.9425 + "x0": 0.214062, + "y0": 0.785, + "x1": 0.282813, + "y1": 0.9525 }, "created_at": "2026-05-07 14:36:25", - "updated_at": "2026-05-07 14:40:55" + "updated_at": "2026-05-08 09:21:02" } ] } diff --git a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py index 10db99e20..3033741bc 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py +++ b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py @@ -68,12 +68,37 @@ class RawProcessorCore: "apply_stage": "after_fusion", "method": "gray_scale_with_white_guard", "space": "multispec_tensor", - "targets": { - "black": 0.06, - "gray": 0.40, - "white": 0.78, + "targets_by_patch_channel": { + "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 + } }, "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_max": 2.50, "clip_output": True, @@ -82,6 +107,7 @@ class RawProcessorCore: "save_patch_stats": True, } self.last_patch_normalization_result = None + self.last_frame_quality_result = None self.camera_settings = {} 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.resize_tensor_chw(tensor, target_size=target_size) tensor = self.apply_patch_normalization_to_tensor(tensor) + self.last_frame_quality_result = self.evaluate_frame_quality(tensor) return tensor, channel_names @@ -295,6 +322,7 @@ class RawProcessorCore: 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) + self.last_frame_quality_result = self.evaluate_frame_quality(tensor) return tensor if frame_type in ("RGB", "MULTISPEC"): @@ -318,6 +346,7 @@ class RawProcessorCore: tensor = raw_np tensor = self.resize_tensor_chw(tensor, target_size=target_size) + self.last_frame_quality_result = self.evaluate_frame_quality(tensor) return tensor raise RuntimeError(f"frame_type não suportado para inferência: {frame_type}") @@ -681,6 +710,10 @@ class RawProcessorCore: "warnings": [], "scales": {}, "patch_stats": {}, + "channel_debug": {}, + "tensor_stats_before": {}, + "tensor_stats_after": {}, + "summary": {}, } if not cfg.get("enabled", False): @@ -720,124 +753,106 @@ class RawProcessorCore: self.last_patch_normalization_result = result return tensor - targets = cfg.get("targets", {}) or {} - gray_target = float(targets.get("gray", 0.40)) + targets_by_patch_channel = self._resolve_targets_by_patch_channel(cfg, rad_cfg) 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)) + 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)) channel_names = ["R", "G", "B", "RE", "NIR"] out = tensor.astype(np.float32).copy() h, w = out.shape[1], out.shape[2] + result["tensor_stats_before"] = self._tensor_channel_stats(out, channel_names) - 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) + # Para o tensor final fusionado, a geometria de referência é o espaço do RGB. + # Como RE/NIR são alinhados por homografia para casar no RGB, as ROIs usadas + # na normalização final devem ser as ROIs da role rgb, com suporte a múltiplas + # 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) - white_roi = roi_from_patch(white) - black_roi = roi_from_patch(black) + result["roi_source"] = "roi_list_by_role.rgb" + result["roi_counts"] = { + "black": len(black_rois), + "gray": len(gray_rois), + "white": len(white_rois), + } - if gray_roi is None: - result["warnings"].append("invalid_gray_roi") + if not gray_rois: + result["warnings"].append("missing_or_invalid_gray_rois") self.last_patch_normalization_result = result return tensor for ci, ch_name in enumerate(channel_names): 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 # ----------------------------- - gx0, gy0, gx1, gy1 = gray_roi - gray_vals = ch[gy0:gy1, gx0:gx1].reshape(-1) + gray_stats = self._measure_patch_rois_for_channel(ch, gray_rois) - if gray_vals.size <= 0: - result["warnings"].append(f"{ch_name}: empty_gray_roi") + if not gray_stats: + result["warnings"].append(f"{ch_name}: empty_gray_rois") 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), - } + result["patch_stats"].setdefault("gray", {})[ch_name] = gray_stats + gray_p50 = float(gray_stats["p50"]) if gray_p50 <= 1e-6: result["warnings"].append(f"{ch_name}: gray_p50_too_low") 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 # ----------------------------- - if white_roi is not None: - wx0, wy0, wx1, wy1 = white_roi - white_vals = ch[wy0:wy1, wx0:wx1].reshape(-1) + if white_rois: + white_stats = self._measure_patch_rois_for_channel(ch, white_rois) - 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) + if white_stats: + result["patch_stats"].setdefault("white", {})[ch_name] = white_stats - 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), - } + white_p50 = float(white_stats["p50"]) + white_sat = float(white_stats["sat_pct"]) 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 + max_scale_by_white = white_guard_ch / 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) + scale_after_white_guard = float(scale) # ----------------------------- # 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), - } + if black_rois: + black_stats = self._measure_patch_rois_for_channel(ch, black_rois) + if black_stats: + result["patch_stats"].setdefault("black", {})[ch_name] = black_stats scale_before_clip = float(scale) 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}" ) - 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] = { "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_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: 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["valid"] = bool(len(result["scales"]) == len(channel_names)) result["clip_output"] = clip_output @@ -867,6 +924,372 @@ class RawProcessorCore: self.last_patch_normalization_result = result 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 já 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): x0 = int(float(roi_pct.get("x0", 0.0)) * w) y0 = int(float(roi_pct.get("y0", 0.0)) * h) diff --git a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core_bkp.py b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core_bkp.py index bfd80ab58..10db99e20 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core_bkp.py +++ b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core_bkp.py @@ -11,6 +11,7 @@ class RawProcessorCore: self.sensor_width = sensor_width self.sensor_height = sensor_height self.bayer_pattern = bayer_pattern.upper() + self.fusion_config = { "alignment_mode": "manual_affine", "baseline_mm": 75.0, @@ -26,6 +27,7 @@ class RawProcessorCore: "resize_after_crop": True, "target_size": None, } + self.rgb_calibration = { "enabled": False, "gains": { @@ -34,8 +36,56 @@ class RawProcessorCore: "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: - self.load_fusion_config_json(calibration_json_path) + self.load_config_json(calibration_json_path) def unpack_raw10_packed( self, @@ -231,107 +281,23 @@ class RawProcessorCore: raise RuntimeError("RGB obrigatório") 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.apply_patch_normalization_to_tensor(tensor) return tensor, channel_names - def build_infer_tensor_from_stream_old(self, frame, meta, channels_expected): - """ - 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 já pronto - - MULTISPEC uint8/float32 já pronto - - RAW_BRUTO multi_payload (cam2 RGB + cam0/cam1 packed) - """ + def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None): 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 not isinstance(frame, dict): - raise RuntimeError("RAW_BRUTO esperado como dict de câmeras no modo multi") + decoded = self.decode_stream_cameras(frame, meta) + 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 = [] - 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 frame_type in ("RGB", "MULTISPEC"): if not isinstance(frame, np.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}") if raw_np.shape[0] != channels_expected: - raise RuntimeError( - f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}" - ) + raise RuntimeError(f"Frame {frame_type} com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected}") + tensor = raw_np - return raw_np - - - 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) + 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}") @@ -450,7 +401,63 @@ class RawProcessorCore: 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): + 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") if rgb_cam_id is None: 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) + 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: 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) - def save_rgb_u8_file(self, path: str, arr: np.ndarray): 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): raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}") @@ -839,57 +1057,433 @@ class RawProcessorCore: fusion = data.get("fusion_config") 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: print("[WARN] JSON sem fusion_config. Mantendo config padrão.") rgb_cal = data.get("rgb_calibration") 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)) - for key, value in loaded_cfg.items(): - if isinstance(value, dict) and isinstance(cfg.get(key), dict): - cfg[key].update(value) - else: - cfg[key] = value + def merge(dst: dict, src: dict): + for key, value in src.items(): + if isinstance(value, dict) and isinstance(dst.get(key), dict): + merge(dst[key], value) + else: + dst[key] = value + + if isinstance(loaded_cfg, dict): + merge(cfg, loaded_cfg) return cfg - def _decode_spectral_frame_to_float01(self, data, cam_meta): - arr = data - if arr.ndim == 3 and arr.shape[2] == 1: - arr = arr[:, :, 0] + def _resolve_calibration_path(self, path: str) -> str: + if not path: + return "" - bit_depth = int(cam_meta.get("bit_depth", 8)) - channels = int(cam_meta.get("channels", 1)) if cam_meta.get("channels") is not None else 1 + path = str(path).replace("\\", "/") - # Caso preview/processado: mono já vem uint8/uint16 normal. - if arr.ndim == 2 and arr.dtype == np.uint8: - return np.clip(arr.astype(np.float32) / 255.0, 0.0, 1.0) + if os.path.isabs(path): + return path - if arr.ndim == 2 and arr.dtype == np.uint16 and bit_depth != 10: - return np.clip(arr.astype(np.float32) / 65535.0, 0.0, 1.0) + # Primeiro tenta relativo ao diretório de execução. + if os.path.isfile(path): + return path - if bit_depth == 10: - sensor_width = int(cam_meta.get("width", self.sensor_width)) - sensor_height = int(cam_meta.get("height", arr.shape[0])) + # Depois tenta relativo ao diretório do module_params.json. + candidate = os.path.join(self.calibration_base_dir, path) + if os.path.isfile(candidate): + return candidate - raw16 = self.unpack_raw10_packed( - arr, - sensor_width=sensor_width, - sensor_height=sensor_height, + # Por fim, se o path já começa com "calibration/", tenta relativo ao pai da pasta calibration. + base_parent = os.path.dirname(self.calibration_base_dir) + candidate = os.path.join(base_parent, path) + 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) - return np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0) + out = np.maximum(base - dark, 0.0) + return out.astype(np.float32, copy=False) - # fallback - arr01 = arr.astype(np.float32) - if arr01.max() > 1.5: - arr01 /= 255.0 + def _apply_flat_gain_single_channel( + self, + img: np.ndarray, + 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) diff --git a/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py b/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py index e3fd7c3f0..5f16cbe43 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py @@ -245,7 +245,7 @@ def main(): if not isinstance(radiometric_normalization_config, dict): radiometric_normalization_config = { - "enabled": True, + "enabled": False, "method": "exposure_gain_reference", "apply_stage": "after_dark_before_flat_gain", "reference_controls": { @@ -255,6 +255,10 @@ def main(): }, "clip_output": True, } + else: + radiometric_normalization_config["enabled"] = bool( + radiometric_normalization_config.get("enabled", False) + ) patch_normalization_config = radiometric_data.get("patch_normalization") if not isinstance(patch_normalization_config, dict): @@ -262,17 +266,30 @@ def main(): if not isinstance(patch_normalization_config, dict): patch_normalization_config = { - "enabled": False, + "enabled": True, "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, + "targets_by_patch_channel": { + "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 + }, }, "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_max": 2.50, "clip_output": True, diff --git a/Python/OAK/datasets/oak-fcc-3/utils/check_saved_files.py b/Python/OAK/datasets/oak-fcc-3/utils/check_saved_files.py index 9a96e9a10..29381b534 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/check_saved_files.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/check_saved_files.py @@ -43,7 +43,7 @@ def save_multispec_tensor_from_raw_group( ensure_dir(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: raise RuntimeError(f"Falha ao gerar tensor MULTISPEC: {desc}") @@ -85,6 +85,9 @@ def save_multispec_tensor_from_raw_group( "description": desc, "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": { "ts": meta.get("ts"), "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)) -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. + Quando disponível, adiciona um resumo de qualidade no subtítulo do painel RGB final. """ if tensor.ndim != 3 or tensor.shape[0] < 5: 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 ) + 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 [ - ("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 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 """ 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 {} 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) - 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]: @@ -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: raise RuntimeError(f"Payload MULTISPEC inválido, shape={arr.shape}") - rgb_hwc = chw_to_hwc(arr[:3].astype(np.float32)) - rgb_bgr = normalize_float01_to_bgr(rgb_hwc) + processing = meta.get("processing", {}) or {} + 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) - nir01 = arr[4].astype(np.float32) - - re_bgr = cv2.cvtColor( - np.clip(re01 * 255.0, 0, 255).astype(np.uint8), - cv2.COLOR_GRAY2BGR + panels = tensor_to_preview_panels( + arr.astype(np.float32), + frame_quality=frame_quality, + patch_result=patch_result, ) - nir_bgr = cv2.cvtColor( - np.clip(nir01 * 255.0, 0, 255).astype(np.uint8), - cv2.COLOR_GRAY2BGR - ) - - panels = [ - ("RGB reconstruido", rgb_bgr, "canais 0,1,2"), - ("RE reconstruido", re_bgr, "canal 3"), - ("NIR reconstruido", nir_bgr, "canal 4"), - ] + # Renomeia os painéis para indicar que vieram de um MULTISPEC já salvo. + renamed = [] + for title, img, subtitle in panels: + title = title.replace("MULTISPEC RGB final", "RGB reconstruido") + title = title.replace("MULTISPEC RE final", "RE reconstruido") + title = title.replace("MULTISPEC NIR final", "NIR reconstruido") + renamed.append((title, img, subtitle)) 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 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": try: - tensor, desc = build_multispec_from_raw_native_multi(group, meta) - tensor_panels = tensor_to_preview_panels(tensor) + tensor, desc, processing_info = build_multispec_from_raw_native_multi(group, meta) + 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, # para substituir o antigo PNG salvo. 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, # 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) if subtitle: - cv2.putText(out, subtitle, (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 3, cv2.LINE_AA) - cv2.putText(out, subtitle, (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA) + # Quebra visual simples para linhas longas de debug/qualidade. + 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 diff --git a/Python/OAK/datasets/oak-fcc-3/utils/flatfield_calibration_tool_quality.py b/Python/OAK/datasets/oak-fcc-3/utils/flatfield_calibration_tool_quality.py new file mode 100644 index 000000000..af4935c39 --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/utils/flatfield_calibration_tool_quality.py @@ -0,0 +1,1345 @@ +import os +import json +import time +import argparse +from datetime import datetime +from pathlib import Path + +import cv2 +import numpy as np + +from core.oak_fcc3_client import OakFcc3Client as MultiSpectralClient + + +# ============================================================ +# Helpers gerais +# ============================================================ + +def now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def ensure_dir(path: str): + os.makedirs(path, exist_ok=True) + + +def safe_float(v, default=None): + try: + if v is None: + return default + return float(v) + except Exception: + return default + + +def safe_int(v, default=None): + try: + if v is None: + return default + return int(v) + except Exception: + return default + + +def overlay_hud( + img_bgr, + lines, + x=12, + y=24, + font_scale=0.58, + line_step=22, + color=(255, 255, 255), + shadow=(0, 0, 0), +): + yy = int(y) + for s in lines: + cv2.putText(img_bgr, str(s), (int(x), yy), cv2.FONT_HERSHEY_SIMPLEX, + font_scale, shadow, 3, cv2.LINE_AA) + cv2.putText(img_bgr, str(s), (int(x), yy), cv2.FONT_HERSHEY_SIMPLEX, + font_scale, color, 1, cv2.LINE_AA) + yy += int(line_step) + + +def to_bgr_u8_from_rgb01(rgb01: np.ndarray) -> np.ndarray: + rgb_u8 = np.clip(rgb01 * 255.0, 0, 255).astype(np.uint8) + return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) + + +def gray_to_bgr_u8(gray01: np.ndarray) -> np.ndarray: + g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8) + return cv2.cvtColor(g, cv2.COLOR_GRAY2BGR) + + +def resize_if_needed(img: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray: + if img is None: + return None + th, tw = target_hw + if img.shape[:2] == (th, tw): + return img + return cv2.resize(img, (tw, th), interpolation=cv2.INTER_LINEAR) + + +def get_decoded_by_role(decoded: dict, role: str): + role = str(role).lower() + for cam_id, item in (decoded or {}).items(): + if str(item.get("role", "")).lower() == role: + return cam_id, item + return None, None + + +def get_image_by_role(decoded: dict, role: str): + cam_id, item = get_decoded_by_role(decoded, role) + if item is None: + return cam_id, None + return cam_id, item.get("image") + + +def validate_module_ready(status: dict, raw_policy: str): + if not status.get("ok", True): + raise RuntimeError(f"Status invalido retornado pelo modulo: {status}") + + active_roles = status.get("active_roles", {}) or {} + active_count = int(status.get("camera_count_active", 0)) + + if raw_policy == "require_triple": + missing = [role for role in ("rgb", "nir", "re") if role not in active_roles] + if missing: + raise RuntimeError( + "RAW_BRUTO com require_triple exige rgb/nir/re ativas. " + f"Faltando: {missing}. Ativas: {active_roles}" + ) + elif active_count < 1: + raise RuntimeError("RAW_BRUTO requer ao menos uma câmera ativa.") + + +def compute_image_stats(img01: np.ndarray) -> dict: + if img01 is None: + return { + "valid": False, + "mean": 0.0, + "std": 0.0, + "p01": 0.0, + "p05": 0.0, + "p50": 0.0, + "p95": 0.0, + "p99": 0.0, + "sat_pct": 0.0, + "dark_pct": 0.0, + } + + arr = img01.astype(np.float32).reshape(-1) + return { + "valid": True, + "mean": float(arr.mean()), + "std": float(arr.std()), + "p01": float(np.percentile(arr, 1)), + "p05": float(np.percentile(arr, 5)), + "p50": float(np.percentile(arr, 50)), + "p95": float(np.percentile(arr, 95)), + "p99": float(np.percentile(arr, 99)), + "sat_pct": float((arr >= 0.985).mean() * 100.0), + "dark_pct": float((arr <= 0.015).mean() * 100.0), + } + + +def robust_center(x: np.ndarray, low_pct=10.0, high_pct=90.0) -> float: + arr = x[np.isfinite(x)].astype(np.float32).reshape(-1) + if arr.size == 0: + return 1.0 + + lo = np.percentile(arr, low_pct) + hi = np.percentile(arr, high_pct) + core = arr[(arr >= lo) & (arr <= hi)] + if core.size == 0: + core = arr + + v = float(np.median(core)) + if not np.isfinite(v) or v <= 1e-8: + return 1.0 + return v + + +def normalize_for_display(img: np.ndarray) -> np.ndarray: + if img is None: + return None + + arr = img.astype(np.float32) + finite = arr[np.isfinite(arr)] + if finite.size == 0: + return np.zeros_like(arr, dtype=np.float32) + + lo = np.percentile(finite, 1) + hi = np.percentile(finite, 99) + if hi <= lo: + hi = lo + 1e-6 + + out = (arr - lo) / (hi - lo) + return np.clip(out, 0.0, 1.0) + + +def smooth_map_gain(gain: np.ndarray, ksize: int) -> np.ndarray: + if ksize is None or ksize <= 1: + return gain.astype(np.float32) + + if ksize % 2 == 0: + ksize += 1 + + return cv2.GaussianBlur( + gain.astype(np.float32), + (ksize, ksize), + sigmaX=0, + sigmaY=0, + borderType=cv2.BORDER_REFLECT, + ) + + +def clip_gain_map(gain: np.ndarray, min_gain: float, max_gain: float) -> np.ndarray: + return np.clip(gain.astype(np.float32), float(min_gain), float(max_gain)).astype(np.float32) + + +# ============================================================ +# Qualidade de calibração flat-field +# ============================================================ + +QUALITY_THRESHOLDS = { + # Frame WHITE antes/durante captura + "white_sat_warning_pct": 0.10, + "white_sat_bad_pct": 0.50, + "white_dark_warning_pct": 1.00, + "white_dark_bad_pct": 5.00, + "white_uniformity_warning": 0.20, + "white_uniformity_bad": 0.35, + "white_side_ratio_low_warning": 0.80, + "white_side_ratio_low_bad": 0.65, + "white_side_ratio_high_warning": 1.25, + "white_side_ratio_high_bad": 1.45, + + # Mapa final de ganho + "gain_p99_warning": 1.60, + "gain_p99_bad": 1.85, + "gain_max_warning": 1.80, + "gain_max_bad": 2.10, + "gain_side_ratio_warning": 1.30, + "gain_side_ratio_bad": 1.55, + "gain_center_low_warning": 0.80, + "gain_center_low_bad": 0.65, + + # Defeitos locais (sujeira/manchas) após remover tendência suave + "white_local_defect_dev_warning": 0.12, + "white_local_defect_dev_bad": 0.20, + "white_local_outlier_pct_warning": 1.00, + "white_local_outlier_pct_bad": 3.00, + "gain_local_defect_dev_warning": 0.10, + "gain_local_defect_dev_bad": 0.18, + "gain_local_outlier_pct_warning": 1.00, + "gain_local_outlier_pct_bad": 3.00, +} + + +def _status_rank(status: str) -> int: + return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 0) + + +def _merge_status(a: str, b: str) -> str: + return a if _status_rank(a) >= _status_rank(b) else b + + +def _append_reason(reasons: list[str], condition: bool, status_ref: str, reason: str): + if condition: + reasons.append(reason) + return status_ref + return "good" + + +def _safe_ratio(num: float, den: float, default: float = 1.0) -> float: + try: + num = float(num) + den = float(den) + if not np.isfinite(num) or not np.isfinite(den) or abs(den) <= 1e-9: + return default + return num / den + except Exception: + return default + + +def _crop_margin(arr: np.ndarray, margin_frac: float = 0.08) -> np.ndarray: + h, w = arr.shape[:2] + mx = int(w * margin_frac) + my = int(h * margin_frac) + x0, x1 = mx, max(mx + 1, w - mx) + y0, y1 = my, max(my + 1, h - my) + return arr[y0:y1, x0:x1] + + +def region_profile_stats(img01: np.ndarray) -> dict: + """ + Mede homogeneidade espacial em 3x3 e também em faixas esquerda/centro/direita. + Usa p50 para ser robusto contra textura fina/ruído. + """ + if img01 is None: + return {"valid": False} + + arr = img01.astype(np.float32) + if arr.ndim == 3: + arr = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32) + + arr = _crop_margin(arr, margin_frac=0.04) + h, w = arr.shape[:2] + if h < 9 or w < 9: + return {"valid": False} + + cells = [] + grid = [] + for gy in range(3): + row = [] + y0 = int(round(gy * h / 3.0)) + y1 = int(round((gy + 1) * h / 3.0)) + for gx in range(3): + x0 = int(round(gx * w / 3.0)) + x1 = int(round((gx + 1) * w / 3.0)) + cell = arr[y0:y1, x0:x1].reshape(-1) + cell = cell[np.isfinite(cell)] + p50 = float(np.percentile(cell, 50)) if cell.size else 0.0 + row.append(p50) + cells.append(p50) + grid.append(row) + + cells_np = np.array(cells, dtype=np.float32) + center = float(grid[1][1]) + if center <= 1e-9 or not np.isfinite(center): + center = float(np.median(cells_np)) if cells_np.size else 1.0 + + left = float(np.median([grid[0][0], grid[1][0], grid[2][0]])) + mid = float(np.median([grid[0][1], grid[1][1], grid[2][1]])) + right = float(np.median([grid[0][2], grid[1][2], grid[2][2]])) + top = float(np.median(grid[0])) + bottom = float(np.median(grid[2])) + + return { + "valid": True, + "grid_p50": grid, + "center_p50": center, + "left_p50": left, + "mid_p50": mid, + "right_p50": right, + "top_p50": top, + "bottom_p50": bottom, + "min_region_p50": float(cells_np.min()), + "max_region_p50": float(cells_np.max()), + "median_region_p50": float(np.median(cells_np)), + "uniformity_delta": _safe_ratio(float(cells_np.max() - cells_np.min()), center, default=0.0), + "left_center_ratio": _safe_ratio(left, center), + "right_center_ratio": _safe_ratio(right, center), + "top_center_ratio": _safe_ratio(top, center), + "bottom_center_ratio": _safe_ratio(bottom, center), + } + + + + +def local_defect_stats(img01: np.ndarray, blur_frac: float = 0.18, margin_frac: float = 0.04, outlier_thr: float = 0.10) -> dict: + """ + Detecta defeitos locais após remover tendência suave (vinheta/gradiente global). + Útil para sujeira na lente, manchas, poeira, gotas e sombras localizadas. + """ + if img01 is None: + return {"valid": False} + + arr = img01.astype(np.float32) + if arr.ndim == 3: + arr = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32) + + arr = _crop_margin(arr, margin_frac=margin_frac) + h, w = arr.shape[:2] + if h < 32 or w < 32: + return {"valid": False} + + k = int(round(min(h, w) * blur_frac)) + k = max(31, k) + if k % 2 == 0: + k += 1 + + smooth = cv2.GaussianBlur(arr, (k, k), sigmaX=0, sigmaY=0, borderType=cv2.BORDER_REFLECT) + ratio = arr / np.maximum(smooth, 1e-6) + ratio = np.clip(ratio, 0.0, 4.0) + resid = ratio - 1.0 + abs_resid = np.abs(resid) + + finite = abs_resid[np.isfinite(abs_resid)] + if finite.size == 0: + return {"valid": False} + + p95 = float(np.percentile(finite, 95)) + p99 = float(np.percentile(finite, 99)) + max_dev = float(np.max(finite)) + outlier_pct = float((finite >= float(outlier_thr)).mean() * 100.0) + + # Percentis direcionais para diagnosticar manchas escuras/claras. + ratio_flat = ratio[np.isfinite(ratio)].reshape(-1) + r_p01 = float(np.percentile(ratio_flat, 1)) if ratio_flat.size else 1.0 + r_p99 = float(np.percentile(ratio_flat, 99)) if ratio_flat.size else 1.0 + + return { + "valid": True, + "blur_ksize": int(k), + "ratio_p01": r_p01, + "ratio_p99": r_p99, + "local_dev_p95": p95, + "local_dev_p99": p99, + "local_dev_max": max_dev, + "local_outlier_pct": outlier_pct, + "outlier_threshold": float(outlier_thr), + } + +def evaluate_white_frame_quality(channels: dict, thresholds: dict | None = None) -> dict: + """Avalia se a cena WHITE/flat está homogênea antes de capturar.""" + th = dict(QUALITY_THRESHOLDS) + if thresholds: + th.update(thresholds) + + result = { + "status": "good", + "reasons": [], + "channels": {}, + "thresholds": th, + } + + for ch, img in (channels or {}).items(): + st = compute_image_stats(img) + rp = region_profile_stats(img) + ld = local_defect_stats(img, outlier_thr=th.get("white_local_defect_dev_warning", 0.10)) + status = "good" + reasons = [] + + sat = float(st.get("sat_pct", 0.0)) + dark = float(st.get("dark_pct", 0.0)) + uniformity = float(rp.get("uniformity_delta", 0.0)) if rp.get("valid") else 0.0 + lcr = float(rp.get("left_center_ratio", 1.0)) if rp.get("valid") else 1.0 + rcr = float(rp.get("right_center_ratio", 1.0)) if rp.get("valid") else 1.0 + tcr = float(rp.get("top_center_ratio", 1.0)) if rp.get("valid") else 1.0 + bcr = float(rp.get("bottom_center_ratio", 1.0)) if rp.get("valid") else 1.0 + ld_dev = float(ld.get("local_dev_p99", 0.0)) if ld.get("valid") else 0.0 + ld_out = float(ld.get("local_outlier_pct", 0.0)) if ld.get("valid") else 0.0 + + if sat > th["white_sat_bad_pct"]: + status = _merge_status(status, "bad"); reasons.append(f"sat_pct_bad:{sat:.2f}%") + elif sat > th["white_sat_warning_pct"]: + status = _merge_status(status, "warning"); reasons.append(f"sat_pct_warning:{sat:.2f}%") + + if dark > th["white_dark_bad_pct"]: + status = _merge_status(status, "bad"); reasons.append(f"dark_pct_bad:{dark:.2f}%") + elif dark > th["white_dark_warning_pct"]: + status = _merge_status(status, "warning"); reasons.append(f"dark_pct_warning:{dark:.2f}%") + + if uniformity > th["white_uniformity_bad"]: + status = _merge_status(status, "bad"); reasons.append(f"uniformity_bad:{uniformity:.2f}") + elif uniformity > th["white_uniformity_warning"]: + status = _merge_status(status, "warning"); reasons.append(f"uniformity_warning:{uniformity:.2f}") + + for name, val in (("left", lcr), ("right", rcr), ("top", tcr), ("bottom", bcr)): + if val < th["white_side_ratio_low_bad"] or val > th["white_side_ratio_high_bad"]: + status = _merge_status(status, "bad"); reasons.append(f"{name}_center_ratio_bad:{val:.2f}") + elif val < th["white_side_ratio_low_warning"] or val > th["white_side_ratio_high_warning"]: + status = _merge_status(status, "warning"); reasons.append(f"{name}_center_ratio_warning:{val:.2f}") + + if ld.get("valid"): + if ld_dev > th["white_local_defect_dev_bad"]: + status = _merge_status(status, "bad"); reasons.append(f"local_defect_dev_bad:{ld_dev:.2f}") + elif ld_dev > th["white_local_defect_dev_warning"]: + status = _merge_status(status, "warning"); reasons.append(f"local_defect_dev_warning:{ld_dev:.2f}") + + if ld_out > th["white_local_outlier_pct_bad"]: + status = _merge_status(status, "bad"); reasons.append(f"local_outlier_pct_bad:{ld_out:.2f}%") + elif ld_out > th["white_local_outlier_pct_warning"]: + status = _merge_status(status, "warning"); reasons.append(f"local_outlier_pct_warning:{ld_out:.2f}%") + + result["channels"][ch] = { + "status": status, + "reasons": reasons, + "stats": st, + "region_profile": rp, + "local_defect": ld, + } + result["status"] = _merge_status(result["status"], status) + result["reasons"].extend([f"{ch}:{r}" for r in reasons[:3]]) + + result["summary"] = { + "channel_count": len(result["channels"]), + "bad_count": sum(1 for v in result["channels"].values() if v.get("status") == "bad"), + "warning_count": sum(1 for v in result["channels"].values() if v.get("status") == "warning"), + } + return result + + +def evaluate_gain_maps_quality(gain_maps: dict, corrected_white: dict | None = None, thresholds: dict | None = None) -> dict: + """Avalia se os mapas de ganho finais estão agressivos demais ou assimétricos.""" + th = dict(QUALITY_THRESHOLDS) + if thresholds: + th.update(thresholds) + + result = { + "status": "good", + "reasons": [], + "channels": {}, + "thresholds": th, + } + + for ch, gain in (gain_maps or {}).items(): + g = gain.astype(np.float32) + finite = g[np.isfinite(g)] + if finite.size == 0: + ch_status = "bad" + reasons = ["gain_map_invalid"] + stats = {} + rp = {"valid": False} + ld = {"valid": False} + else: + stats = { + "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)), + } + rp = region_profile_stats(g) + ld = local_defect_stats(g, outlier_thr=th.get("gain_local_defect_dev_warning", 0.08)) + ch_status = "good" + reasons = [] + + if stats["p99"] > th["gain_p99_bad"]: + ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_p99_bad:{stats['p99']:.2f}") + elif stats["p99"] > th["gain_p99_warning"]: + ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_p99_warning:{stats['p99']:.2f}") + + if stats["max"] > th["gain_max_bad"]: + ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_max_bad:{stats['max']:.2f}") + elif stats["max"] > th["gain_max_warning"]: + ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_max_warning:{stats['max']:.2f}") + + if rp.get("valid"): + center = float(rp.get("center_p50", 1.0)) + rcr = float(rp.get("right_center_ratio", 1.0)) + lcr = float(rp.get("left_center_ratio", 1.0)) + center_gain = center + + for name, ratio in (("left", lcr), ("right", rcr)): + inv = max(ratio, 1.0 / max(ratio, 1e-6)) + if inv > th["gain_side_ratio_bad"]: + ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_{name}_center_ratio_bad:{ratio:.2f}") + elif inv > th["gain_side_ratio_warning"]: + ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_{name}_center_ratio_warning:{ratio:.2f}") + + if center_gain < th["gain_center_low_bad"]: + ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_center_low_bad:{center_gain:.2f}") + elif center_gain < th["gain_center_low_warning"]: + ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_center_low_warning:{center_gain:.2f}") + + if ld.get("valid"): + ld_dev = float(ld.get("local_dev_p99", 0.0)) + ld_out = float(ld.get("local_outlier_pct", 0.0)) + if ld_dev > th["gain_local_defect_dev_bad"]: + ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_local_defect_dev_bad:{ld_dev:.2f}") + elif ld_dev > th["gain_local_defect_dev_warning"]: + ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_local_defect_dev_warning:{ld_dev:.2f}") + + if ld_out > th["gain_local_outlier_pct_bad"]: + ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_local_outlier_pct_bad:{ld_out:.2f}%") + elif ld_out > th["gain_local_outlier_pct_warning"]: + ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_local_outlier_pct_warning:{ld_out:.2f}%") + + white_quality = None + if corrected_white is not None and ch in corrected_white: + white_quality = evaluate_white_frame_quality({ch: corrected_white[ch]}, thresholds=th).get("channels", {}).get(ch) + + result["channels"][ch] = { + "status": ch_status, + "reasons": reasons, + "gain_stats": stats, + "gain_region_profile": rp, + "local_defect": ld, + "white_signal_quality": white_quality, + } + result["status"] = _merge_status(result["status"], ch_status) + result["reasons"].extend([f"{ch}:{r}" for r in reasons[:4]]) + + result["summary"] = { + "channel_count": len(result["channels"]), + "bad_count": sum(1 for v in result["channels"].values() if v.get("status") == "bad"), + "warning_count": sum(1 for v in result["channels"].values() if v.get("status") == "warning"), + } + return result + + +def format_quality_lines(report: dict | None, title: str = "QUALITY", max_channels: int = 5, max_reasons: int = 5) -> list[str]: + if not report: + return [f"{title}: sem dados"] + + status = str(report.get("status", "unknown")).upper() + lines = [f"{title}: {status}"] + + channels = report.get("channels", {}) or {} + for ch in list(channels.keys())[:max_channels]: + item = channels.get(ch, {}) or {} + st = str(item.get("status", "unknown")).upper() + rp = item.get("region_profile") or item.get("gain_region_profile") or {} + stats = item.get("stats") or item.get("gain_stats") or {} + ld = item.get("local_defect") or {} + ld_txt = "" + if isinstance(ld, dict) and ld.get("valid"): + ld_txt = f" LD={ld.get('local_dev_p99', 0):.2f}/{ld.get('local_outlier_pct', 0):.1f}%" + if rp.get("valid"): + lines.append( + f"{ch}: {st} | p50={stats.get('p50', stats.get('p50', 0)):.3f} " + f"unif={rp.get('uniformity_delta', 0):.2f} " + f"L/C={rp.get('left_center_ratio', 1):.2f} " + f"R/C={rp.get('right_center_ratio', 1):.2f}{ld_txt}" + ) + else: + lines.append(f"{ch}: {st}{ld_txt}") + + reasons = report.get("reasons", []) or [] + for r in reasons[:max_reasons]: + lines.append(f"! {r}") + + if len(reasons) > max_reasons: + lines.append(f"! +{len(reasons) - max_reasons} avisos") + + return lines + + +# ============================================================ +# Controle de câmera e extração de canais +# ============================================================ + +def get_controls_for_role(cam: MultiSpectralClient, role: str) -> dict: + try: + ctrl = cam.svc.get_camera_controls(role=role) or {} + return { + "ok": True, + "role": role, + "ae_enable": bool(ctrl.get("ae_enable", False)), + "awb_enable": bool(ctrl.get("awb_enable", False)), + "exposure_time_us": safe_int(ctrl.get("exposure_time_us"), None), + "analogue_gain": safe_float(ctrl.get("analogue_gain"), None), + "colour_gains": ctrl.get("colour_gains", None), + "raw": ctrl, + } + except Exception as e: + return { + "ok": False, + "role": role, + "error": str(e), + "ae_enable": None, + "awb_enable": None, + "exposure_time_us": None, + "analogue_gain": None, + "colour_gains": None, + } + + +def get_all_controls(cam: MultiSpectralClient) -> dict: + return {role: get_controls_for_role(cam, role) for role in ("rgb", "re", "nir")} + + +def exposure_gain_factor(ctrl: dict, fallback_exp=1.0, fallback_gain=1.0) -> float: + exp = safe_float(ctrl.get("exposure_time_us"), fallback_exp) + gain = safe_float(ctrl.get("analogue_gain"), fallback_gain) + + if exp is None or exp <= 0: + exp = fallback_exp + if gain is None or gain <= 0: + gain = fallback_gain + + return float(exp * gain) + + +def extract_channels_from_decoded(decoded: dict) -> dict: + """ + Retorna canais em float32 0..1 no espaço nativo de cada câmera: + R/G/B vêm do debayer da role rgb. + RE vem da role re. + NIR vem da role nir. + """ + _, rgb01 = get_image_by_role(decoded, "rgb") + _, re01 = get_image_by_role(decoded, "re") + _, nir01 = get_image_by_role(decoded, "nir") + + def assert_not_raw10_packed_image(role, img, expected_w=1280): + if img is None: + return + + if img.ndim == 2 and img.shape[1] == int(expected_w * 10 / 8): + raise RuntimeError( + f"{role.upper()} parece RAW10_PACKED interpretado como imagem: " + f"shape={img.shape}. Esperado decodificado com largura {expected_w}." + ) + + assert_not_raw10_packed_image("re", re01, expected_w=1280) + assert_not_raw10_packed_image("nir", nir01, expected_w=1280) + + out = {} + + if rgb01 is not None: + rgb01 = rgb01.astype(np.float32) + if rgb01.ndim == 3 and rgb01.shape[2] >= 3: + out["R"] = rgb01[:, :, 0].copy() + out["G"] = rgb01[:, :, 1].copy() + out["B"] = rgb01[:, :, 2].copy() + + if re01 is not None: + out["RE"] = re01.astype(np.float32).copy() + + if nir01 is not None: + out["NIR"] = nir01.astype(np.float32).copy() + + return out + + +def channel_to_role(ch: str) -> str: + ch = ch.upper() + if ch in ("R", "G", "B"): + return "rgb" + if ch == "RE": + return "re" + if ch == "NIR": + return "nir" + raise ValueError(f"Canal desconhecido: {ch}") + + +def apply_exp_gain_correction(channels: dict, controls: dict, enabled: bool) -> dict: + if not enabled: + return {k: v.astype(np.float32).copy() for k, v in channels.items()} + + corrected = {} + for ch, img in channels.items(): + role = channel_to_role(ch) + ctrl = controls.get(role, {}) or {} + factor = exposure_gain_factor(ctrl, fallback_exp=1.0, fallback_gain=1.0) + corrected[ch] = (img.astype(np.float32) / max(factor, 1e-6)).astype(np.float32) + + return corrected + + +# ============================================================ +# UI +# ============================================================ + +def build_board(decoded, controls, state_lines, progress_lines, preview_scale=1.0, quality_lines=None): + rgb_id, rgb01 = get_image_by_role(decoded, "rgb") + re_id, re01 = get_image_by_role(decoded, "re") + nir_id, nir01 = get_image_by_role(decoded, "nir") + + if rgb01 is not None: + rgb_panel = to_bgr_u8_from_rgb01(rgb01) + base_h, base_w = rgb01.shape[:2] + else: + base_h, base_w = 800, 1280 + rgb_panel = np.zeros((base_h, base_w, 3), dtype=np.uint8) + overlay_hud(rgb_panel, ["RGB", "sem frame"]) + + re01 = resize_if_needed(re01, (base_h, base_w)) if re01 is not None else None + nir01 = resize_if_needed(nir01, (base_h, base_w)) if nir01 is not None else None + + re_panel = gray_to_bgr_u8(re01) if re01 is not None else np.zeros_like(rgb_panel) + nir_panel = gray_to_bgr_u8(nir01) if nir01 is not None else np.zeros_like(rgb_panel) + + rgb_stats = compute_image_stats(rgb01[:, :, 1] if rgb01 is not None and rgb01.ndim == 3 else None) + re_stats = compute_image_stats(re01) + nir_stats = compute_image_stats(nir01) + + overlay_hud(rgb_panel, [ + f"RGB ({rgb_id})", + f"p50={rgb_stats['p50']:.3f} p95={rgb_stats['p95']:.3f} sat={rgb_stats['sat_pct']:.2f}%", + f"EXP={controls.get('rgb', {}).get('exposure_time_us')} GAIN={controls.get('rgb', {}).get('analogue_gain')}", + ]) + + overlay_hud(re_panel, [ + f"RE ({re_id})", + f"p50={re_stats['p50']:.3f} p95={re_stats['p95']:.3f} sat={re_stats['sat_pct']:.2f}%", + f"EXP={controls.get('re', {}).get('exposure_time_us')} GAIN={controls.get('re', {}).get('analogue_gain')}", + ]) + + overlay_hud(nir_panel, [ + f"NIR ({nir_id})", + f"p50={nir_stats['p50']:.3f} p95={nir_stats['p95']:.3f} sat={nir_stats['sat_pct']:.2f}%", + f"EXP={controls.get('nir', {}).get('exposure_time_us')} GAIN={controls.get('nir', {}).get('analogue_gain')}", + ]) + + data_panel = np.zeros_like(rgb_panel) + + lines = [] + lines.extend(state_lines) + lines.append("") + lines.extend(progress_lines) + if quality_lines: + lines.append("") + lines.extend(quality_lines) + lines.append("") + lines.extend([ + "ENTER = iniciar etapa atual", + "S = pular etapa dark/preto", + "Q / ESC = sair sem salvar", + "", + "Dica: branco/preto devem preencher todo o campo de visao.", + "Para dark-frame perfeito, tampe as lentes em vez de usar fundo preto.", + ]) + + overlay_hud(data_panel, lines, x=18, y=34, font_scale=0.58, line_step=24) + + top = np.hstack([rgb_panel, re_panel]) + bottom = np.hstack([nir_panel, data_panel]) + board = np.vstack([top, bottom]) + + if preview_scale != 1.0: + board = cv2.resize( + board, + (int(board.shape[1] * preview_scale), int(board.shape[0] * preview_scale)), + interpolation=cv2.INTER_NEAREST, + ) + + return board + + +# ============================================================ +# Captura e processamento +# ============================================================ + +def capture_stage( + cam: MultiSpectralClient, + stage_name: str, + frames_count: int, + discard_frames: int, + exp_gain_correct: bool, + preview_scale: float, + window_name: str, +): + """ + Captura frames decodificados e retorna: + channel_stack: dict canal -> list[np.ndarray] + controls_log: lista dos controles reais lidos por frame + meta_log: lista de metadados básicos por frame + """ + channel_stack = {} + controls_log = [] + meta_log = [] + + total_target = int(frames_count) + captured = 0 + seen_frame_ids = set() + last_decoded = {} + last_controls = {} + + while captured < total_target: + frame, meta, decoded = cam.get_next_decoded(timeout=2.0) + if meta is None or frame is None: + continue + + frame_id = meta.get("frame_id") + if frame_id in seen_frame_ids: + continue + seen_frame_ids.add(frame_id) + + last_decoded = decoded + last_controls = get_all_controls(cam) + + if len(seen_frame_ids) <= discard_frames: + progress_lines = [ + f"Etapa: {stage_name}", + f"Descartando frames iniciais: {len(seen_frame_ids)}/{discard_frames}", + "Aguardando estabilizacao de exposicao/stream...", + ] + board = build_board( + decoded=last_decoded, + controls=last_controls, + state_lines=[f"CALIBRACAO FLAT-FIELD - {stage_name.upper()}"], + progress_lines=progress_lines, + preview_scale=preview_scale, + ) + cv2.imshow(window_name, board) + cv2.waitKey(1) + continue + + channels = extract_channels_from_decoded(decoded) + channels = apply_exp_gain_correction(channels, last_controls, enabled=exp_gain_correct) + + for ch, img in channels.items(): + channel_stack.setdefault(ch, []).append(img.astype(np.float32).copy()) + + meta_log.append({ + "frame_id": frame_id, + "sync_ok": meta.get("sync_ok"), + "sync_dt_ms": meta.get("sync_dt_ms"), + "timestamps": meta.get("timestamps"), + }) + controls_log.append(last_controls) + + captured += 1 + + progress_lines = [ + f"Etapa: {stage_name}", + f"Capturando: {captured}/{total_target}", + f"exp_gain_correction={'ON' if exp_gain_correct else 'OFF'}", + ] + + board = build_board( + decoded=last_decoded, + controls=last_controls, + state_lines=[f"CALIBRACAO FLAT-FIELD - {stage_name.upper()}"], + progress_lines=progress_lines, + preview_scale=preview_scale, + ) + + # Barra de progresso simples. + h, w = board.shape[:2] + pct = captured / max(total_target, 1) + cv2.rectangle(board, (30, h - 38), (w - 30, h - 18), (80, 80, 80), -1) + cv2.rectangle(board, (30, h - 38), (30 + int((w - 60) * pct), h - 18), (0, 220, 0), -1) + + cv2.imshow(window_name, board) + + k = cv2.waitKey(1) & 0xFF + if k in (ord("q"), ord("Q"), 27): + raise KeyboardInterrupt("Captura cancelada pelo usuario.") + + return channel_stack, controls_log, meta_log + + +def median_stack(channel_stack: dict) -> dict: + med = {} + for ch, frames in channel_stack.items(): + if not frames: + continue + arr = np.stack(frames, axis=0).astype(np.float32) + med[ch] = np.median(arr, axis=0).astype(np.float32) + return med + + +def build_gain_maps( + white_med: dict, + dark_med: dict | None, + epsilon: float, + smooth_ksize: int, + min_gain: float, + max_gain: float, +): + gain_maps = {} + flat_norm_maps = {} + corrected_white = {} + dark_used = {} + + for ch, white in white_med.items(): + white = white.astype(np.float32) + + if dark_med is not None and ch in dark_med: + dark = resize_if_needed(dark_med[ch].astype(np.float32), white.shape[:2]) + else: + dark = np.zeros_like(white, dtype=np.float32) + + signal = white - dark + signal = np.maximum(signal, float(epsilon)).astype(np.float32) + + center = robust_center(signal, low_pct=10.0, high_pct=90.0) + flat_norm = signal / max(center, epsilon) + + gain = center / np.maximum(signal, epsilon) + gain = smooth_map_gain(gain, smooth_ksize) + gain = clip_gain_map(gain, min_gain=min_gain, max_gain=max_gain) + + gain_maps[ch] = gain.astype(np.float32) + flat_norm_maps[ch] = flat_norm.astype(np.float32) + corrected_white[ch] = signal.astype(np.float32) + dark_used[ch] = dark.astype(np.float32) + + return gain_maps, flat_norm_maps, corrected_white, dark_used + + +def save_preview_maps(out_dir: str, gain_maps: dict, corrected_white: dict): + ensure_dir(out_dir) + + for ch, gain in gain_maps.items(): + gain_vis = normalize_for_display(gain) + cv2.imwrite(os.path.join(out_dir, f"gain_{ch}.png"), gray_to_bgr_u8(gain_vis)) + + for ch, white in corrected_white.items(): + white_vis = normalize_for_display(white) + cv2.imwrite(os.path.join(out_dir, f"white_signal_{ch}.png"), gray_to_bgr_u8(white_vis)) + + +def show_final_preview(window_name: str, gain_maps: dict, preview_scale: float, quality_report: dict | None = None): + order = ["R", "G", "B", "RE", "NIR"] + panels = [] + + # RGB e mono podem sair com tamanhos diferentes dependendo do decode/preview. + # Para o painel final, padronizamos tudo para o maior H/W encontrado. + shapes = [gain_maps[ch].shape[:2] for ch in order if ch in gain_maps] + if not shapes: + return + + target_h = max(s[0] for s in shapes) + target_w = max(s[1] for s in shapes) + + def fit_panel(img_bgr: np.ndarray) -> np.ndarray: + if img_bgr.shape[:2] == (target_h, target_w): + return img_bgr + return cv2.resize(img_bgr, (target_w, target_h), interpolation=cv2.INTER_NEAREST) + + for ch in order: + if ch not in gain_maps: + panel = np.zeros((target_h, target_w, 3), dtype=np.uint8) + overlay_hud(panel, [ch, "sem mapa"]) + else: + vis = normalize_for_display(gain_maps[ch]) + panel = gray_to_bgr_u8(vis) + panel = fit_panel(panel) + g = gain_maps[ch] + overlay_hud(panel, [ + f"GAIN MAP {ch}", + f"shape={list(g.shape)}", + f"min={float(np.min(g)):.3f} max={float(np.max(g)):.3f}", + f"mean={float(np.mean(g)):.3f} std={float(np.std(g)):.3f}", + ]) + panels.append(panel) + + blank = np.zeros((target_h, target_w, 3), dtype=np.uint8) + final_lines = [ + "Flat-field salvo com sucesso.", + "ENTER/qualquer tecla = fechar", + "", + ] + final_lines.extend(format_quality_lines(quality_report, title="FINAL QUALITY", max_channels=5, max_reasons=8)) + final_lines.extend([ + "", + "Use estes mapas antes da fusao geometrica.", + "", + "Obs: RGB e mono podem ter shapes diferentes;", + "isso e normal se o decode gerar resolucoes distintas.", + ]) + overlay_hud(blank, final_lines, x=18, y=36) + + top = np.hstack([panels[0], panels[1], panels[2]]) + bottom = np.hstack([panels[3], panels[4], blank]) + board = np.vstack([top, bottom]) + + if preview_scale != 1.0: + board = cv2.resize( + board, + (int(board.shape[1] * preview_scale), int(board.shape[0] * preview_scale)), + interpolation=cv2.INTER_NEAREST, + ) + + cv2.imshow(window_name, board) + cv2.waitKey(0) + + +def wait_for_enter_or_skip( + cam: MultiSpectralClient, + window_name: str, + title: str, + instruction_lines: list[str], + preview_scale: float, + allow_skip=False, + quality_check_white=False, + require_good_quality=False, +): + while True: + frame, meta, decoded = cam.get_next_decoded(timeout=2.0) + if meta is None or frame is None: + continue + + controls = get_all_controls(cam) + + progress_lines = list(instruction_lines) + quality_report = None + quality_lines = None + if quality_check_white: + try: + channels_now = extract_channels_from_decoded(decoded) + quality_report = evaluate_white_frame_quality(channels_now) + quality_lines = format_quality_lines(quality_report, title="WHITE PREFLIGHT") + if require_good_quality and quality_report.get("status") != "good": + progress_lines.append("") + progress_lines.append("ENTER bloqueado: ajuste fundo/luz ate WHITE PREFLIGHT=GOOD.") + else: + progress_lines.append("") + progress_lines.append("ENTER inicia. Use o status abaixo para decidir se esta seguro.") + except Exception as e: + quality_report = {"status": "bad", "reasons": [f"preflight_error:{e}"], "channels": {}} + quality_lines = format_quality_lines(quality_report, title="WHITE PREFLIGHT") + + print(f"QUALITY_LINES: {quality_lines}") + + if allow_skip: + progress_lines.append("") + progress_lines.append("S = pular esta etapa") + + board = build_board( + decoded=decoded, + controls=controls, + state_lines=[title], + progress_lines=progress_lines, + preview_scale=preview_scale, + quality_lines=quality_lines, + ) + cv2.imshow(window_name, board) + + k = cv2.waitKey(1) & 0xFF + if k in (13, 10): + if quality_check_white and require_good_quality and quality_report and quality_report.get("status") != "good": + continue + return "start" + if allow_skip and k in (ord("s"), ord("S")): + return "skip" + if k in (ord("q"), ord("Q"), 27): + raise KeyboardInterrupt("Cancelado pelo usuario.") + + +# ============================================================ +# Main +# ============================================================ + +def main(): + parser = argparse.ArgumentParser( + description="Calibrador automatico de flat-field/dark-frame para o módulo RGB/RE/NIR OAK-FCC-3.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument("--fps", type=int, default=20) + parser.add_argument("--width", type=int, default=1280) + parser.add_argument("--height", type=int, default=800) + parser.add_argument("--bayer", default="RGGB", choices=["GBRG", "GRBG", "RGGB", "BGGR"]) + parser.add_argument("--capture_mode", default="AUTO", choices=["AUTO", "SINGLE", "DOUBLE", "TRIPLE"]) + parser.add_argument("--raw_policy", default="require_triple", choices=["allow_single", "require_triple"]) + parser.add_argument("--module_calibration_json", default="calibration/module_params.json") + + parser.add_argument("--frames", type=int, default=60, help="Frames úteis capturados por etapa.") + parser.add_argument("--discard_frames", type=int, default=15, help="Frames descartados antes de cada etapa.") + parser.add_argument("--preview_scale", type=float, default=0.65) + + parser.add_argument("--out_npz", default="calibration/flatfield_maps_v1.npz") + parser.add_argument("--out_json", default="calibration/flatfield_maps_v1.json") + parser.add_argument("--preview_dir", default="calibration/flatfield_previews") + + parser.add_argument("--smooth_ksize", type=int, default=31, help="Kernel gaussiano para suavizar mapa de ganho. Use 1 para desligar.") + parser.add_argument("--min_gain", type=float, default=0.25) + parser.add_argument("--max_gain", type=float, default=4.0) + parser.add_argument("--epsilon", type=float, default=1e-8) + + parser.add_argument("--exp_gain_correct", action="store_true", + help="Divide frames por exposure_time_us*analogue_gain antes de calcular o flat.") + parser.add_argument("--skip_dark", action="store_true", + help="Pula etapa dark/preto. O mapa será calculado sem subtração dark.") + parser.add_argument("--notes", default="") + parser.add_argument("--require_good_preflight", action="store_true", + help="Bloqueia ENTER na etapa WHITE se o preflight estiver BAD/WARNING.") + + args = parser.parse_args() + + ensure_dir(os.path.dirname(args.out_npz) or ".") + ensure_dir(os.path.dirname(args.out_json) or ".") + ensure_dir(args.preview_dir) + + window_name = "Flat Field Calibration Tool" + cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) + + session_meta = { + "schema": "multispec_flatfield_v1", + "created_at": now_str(), + "sensor_width": args.width, + "sensor_height": args.height, + "bayer_pattern": args.bayer, + "fps": args.fps, + "capture_mode_requested": args.capture_mode, + "raw_policy": args.raw_policy, + "module_calibration_json": args.module_calibration_json, + "frames_per_stage": args.frames, + "discard_frames": args.discard_frames, + "exp_gain_correct": bool(args.exp_gain_correct), + "smooth_ksize": args.smooth_ksize, + "min_gain": args.min_gain, + "max_gain": args.max_gain, + "epsilon": args.epsilon, + "channels": ["R", "G", "B", "RE", "NIR"], + "notes": args.notes, + "require_good_preflight": bool(args.require_good_preflight), + "stages": {}, + "outputs": { + "npz": args.out_npz, + "json": args.out_json, + "preview_dir": args.preview_dir, + }, + } + + try: + with MultiSpectralClient( + width=args.width, + height=args.height, + bayer=args.bayer, + fps=args.fps, + frame_type="RAW_BRUTO", + output_dtype="uint8", + capture_mode=args.capture_mode, + raw_policy=args.raw_policy, + module_calibration_json=args.module_calibration_json or None + ) as cam: + + validate_module_ready(cam.get_status(), args.raw_policy) + + wait_for_enter_or_skip( + cam=cam, + window_name=window_name, + title="ETAPA 1/2 - WHITE / FLAT FIELD", + instruction_lines=[ + "Posicione o modulo na altura real de operacao.", + "Aponte para uma superficie branca/cinza fosca, uniforme e sem textura.", + "Evite reflexos, sombras laterais e saturacao.", + "A superficie deve preencher todo o campo de visao.", + "Pressione ENTER para comecar a captura WHITE.", + ], + preview_scale=args.preview_scale, + allow_skip=False, + quality_check_white=True, + require_good_quality=args.require_good_preflight, + ) + + white_stack, white_controls, white_meta = capture_stage( + cam=cam, + stage_name="white", + frames_count=args.frames, + discard_frames=args.discard_frames, + exp_gain_correct=args.exp_gain_correct, + preview_scale=args.preview_scale, + window_name=window_name, + ) + + session_meta["stages"]["white"] = { + "controls_log": white_controls, + "meta_log": white_meta, + "captured_channels": {ch: len(v) for ch, v in white_stack.items()}, + } + + dark_stack = None + dark_controls = [] + dark_meta = [] + + if not args.skip_dark: + action = wait_for_enter_or_skip( + cam=cam, + window_name=window_name, + title="ETAPA 2/2 - DARK / PRETO", + instruction_lines=[ + "Agora faca a captura dark/preto.", + "Melhor opcao: tampe as lentes completamente.", + "Alternativa: use fundo preto fosco preenchendo todo o frame.", + "Mantenha exposicao/ganho iguais aos da etapa anterior, se possivel.", + "Pressione ENTER para capturar DARK/PRETO.", + ], + preview_scale=args.preview_scale, + allow_skip=True, + ) + + if action == "start": + dark_stack, dark_controls, dark_meta = capture_stage( + cam=cam, + stage_name="dark", + frames_count=args.frames, + discard_frames=args.discard_frames, + exp_gain_correct=args.exp_gain_correct, + preview_scale=args.preview_scale, + window_name=window_name, + ) + + session_meta["stages"]["dark"] = { + "controls_log": dark_controls, + "meta_log": dark_meta, + "captured_channels": {ch: len(v) for ch, v in dark_stack.items()}, + } + else: + session_meta["stages"]["dark"] = {"skipped": True} + else: + session_meta["stages"]["dark"] = {"skipped": True} + + # Processamento robusto. + processing_panel = np.zeros((720, 1280, 3), dtype=np.uint8) + overlay_hud(processing_panel, [ + "Processando calibracao flat-field...", + "Calculando medianas robustas por canal.", + "Gerando mapas de ganho e previews.", + ], x=40, y=80, font_scale=0.8, line_step=34) + cv2.imshow(window_name, processing_panel) + cv2.waitKey(1) + + white_med = median_stack(white_stack) + dark_med = median_stack(dark_stack) if dark_stack is not None else None + + gain_maps, flat_norm_maps, corrected_white, dark_used = build_gain_maps( + white_med=white_med, + dark_med=dark_med, + epsilon=args.epsilon, + smooth_ksize=args.smooth_ksize, + min_gain=args.min_gain, + max_gain=args.max_gain, + ) + + white_median_quality = evaluate_white_frame_quality(white_med) + final_quality_report = evaluate_gain_maps_quality(gain_maps, corrected_white=corrected_white) + session_meta["white_median_quality"] = white_median_quality + session_meta["quality_report"] = final_quality_report + + # Salva .npz. + save_payload = {} + for ch, arr in gain_maps.items(): + save_payload[f"gain_{ch}"] = arr.astype(np.float32) + for ch, arr in flat_norm_maps.items(): + save_payload[f"flat_norm_{ch}"] = arr.astype(np.float32) + for ch, arr in white_med.items(): + save_payload[f"white_median_{ch}"] = arr.astype(np.float32) + if dark_med is not None: + for ch, arr in dark_med.items(): + save_payload[f"dark_median_{ch}"] = arr.astype(np.float32) + + np.savez_compressed(args.out_npz, **save_payload) + + # Estatísticas finais. + session_meta["maps"] = {} + for ch, gain in gain_maps.items(): + session_meta["maps"][ch] = { + "shape": list(gain.shape), + "gain_key": f"gain_{ch}", + "flat_norm_key": f"flat_norm_{ch}", + "white_median_key": f"white_median_{ch}", + "dark_median_key": f"dark_median_{ch}" if dark_med is not None and ch in dark_med else None, + "gain_min": float(np.min(gain)), + "gain_max": float(np.max(gain)), + "gain_mean": float(np.mean(gain)), + "gain_std": float(np.std(gain)), + "white_signal_stats": compute_image_stats(normalize_for_display(corrected_white[ch])), + "quality": (final_quality_report.get("channels", {}).get(ch, {}) if isinstance(final_quality_report, dict) else {}), + } + + save_preview_maps(args.preview_dir, gain_maps, corrected_white) + + with open(args.out_json, "w", encoding="utf-8") as f: + json.dump(session_meta, f, ensure_ascii=False, indent=2) + + show_final_preview(window_name, gain_maps, args.preview_scale, quality_report=final_quality_report) + + print("") + print("[OK] Flat-field gerado com sucesso.") + print(f"[OK] NPZ : {args.out_npz}") + print(f"[OK] JSON: {args.out_json}") + print(f"[OK] PNGs: {args.preview_dir}") + print(f"[QUALITY] status={final_quality_report.get('status')} reasons={final_quality_report.get('reasons', [])[:6]}") + + except KeyboardInterrupt as e: + print(f"[CANCELADO] {e}") + + finally: + cv2.destroyAllWindows() + + +if __name__ == "__main__": + main() diff --git a/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py index 0f5d6ea78..1ef9eee00 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py @@ -222,26 +222,26 @@ def base_ae_contract(): "verbose": True, "control_metric": "p50", - "target_value": 0.40, - "deadband": 0.04, + "target_value": 0.34, + "deadband": 0.035, - "p95_limit": 0.90, + "p95_limit": 0.94, "saturation_limit_pct": 0.50, "dark_limit_pct": 35.0, # Novo controle proporcional por razão "control_strategy": "ratio", - "ratio_alpha": 0.55, - "ratio_min": 0.55, - "ratio_max": 1.85, + "ratio_alpha": 0.35, + "ratio_min": 0.65, + "ratio_max": 1.35, # Redução rápida quando satura - "reduce_fast_factor": 0.75, + "reduce_fast_factor": 0.80, # Mantém compatibilidade com o modo antigo "alpha": 0.18, "exp_step_gain": 0.55, - "factor_min": 0.72, + "factor_min": 0.55, "factor_max": 1.28, "prefer_exposure": True, @@ -263,12 +263,12 @@ def base_ae_contract(): "role_limits": { "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}, - "nir": {"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": 3000, "gain_min": 1.0, "gain_max": 2.0}, }, - "exp_apply_threshold_us": 40, - "gain_apply_threshold": 0.03, + "exp_apply_threshold_us": 80, + "gain_apply_threshold": 0.05, "ready_required_cycles": 3, @@ -322,6 +322,12 @@ def make_default_patch(patch_type: str, target: float, weight: float): def default_profile_patches(): 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({ "metering_mode": "reference_patches", "spectral_control_mode": "shared", @@ -348,7 +354,7 @@ def default_profile_patches(): "reference_patches": [ 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), ], }) @@ -399,12 +405,37 @@ def load_or_default_config(path: str): "apply_stage": "after_fusion", "method": "gray_scale_with_white_guard", "space": "multispec_tensor", - "targets": { - "black": 0.06, - "gray": 0.40, - "white": 0.78 + "targets_by_patch_channel": { + "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 + } }, "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_max": 2.50, "clip_output": True, @@ -1348,12 +1379,37 @@ def main(): "apply_stage": "after_fusion", "method": "gray_scale_with_white_guard", "space": "multispec_tensor", - "targets": { - "black": 0.06, - "gray": 0.40, - "white": 0.78 + "targets_by_patch_channel": { + "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 + } }, "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_max": 2.50, "clip_output": True,