ajustes na calibragem pelos cartões wb

This commit is contained in:
Diego Freitas 2026-05-07 09:55:08 -03:00
parent 6e166748cf
commit a028c12a3a
22 changed files with 5292 additions and 3430 deletions

View File

@ -407,8 +407,9 @@ 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(
session_dir,
frame_type=frame_type_save,
@ -469,6 +470,7 @@ 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(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,6 +1,6 @@
{
"schema": "multispec_module_params_v3",
"saved_at": "2026-05-06 10:16:07",
"saved_at": "2026-05-07 09:03:53",
"frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO",
@ -12,7 +12,7 @@
"rgb": {
"ae_enable": false,
"awb_enable": false,
"exposure_time_us": 2000,
"exposure_time_us": 4800,
"analogue_gain": 1.0,
"colour_gains": [
1.0,
@ -22,14 +22,14 @@
"re": {
"ae_enable": false,
"awb_enable": false,
"exposure_time_us": 5000,
"exposure_time_us": 13200,
"analogue_gain": 1.0,
"colour_gains": null
},
"nir": {
"ae_enable": false,
"awb_enable": false,
"exposure_time_us": 5000,
"exposure_time_us": 13200,
"analogue_gain": 1.0,
"colour_gains": null
}
@ -94,20 +94,13 @@
"enabled": true,
"interval_s": 0.5,
"verbose": true,
"metering_mode": "global",
"metering_mode": "reference_patches",
"spectral_control_mode": "shared",
"global_roi_pct": {
"x0": 0.082812,
"y0": 0.08,
"x1": 0.903125,
"y1": 0.905
},
"control_metric": "p50",
"target_value": 0.4,
"deadband": 0.04,
"p95_limit": 0.94,
"saturation_limit_pct": 1.0,
"dark_limit_pct": 35.0,
"target_value": 0.5,
"deadband": 0.035,
"p95_limit": 0.95,
"saturation_limit_pct": 2.0,
"alpha": 0.18,
"exp_step_gain": 0.55,
"prefer_exposure": true,
@ -115,46 +108,231 @@
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"role_limits": {
"rgb": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0
"reference_patches": [
{
"name": "black_reference",
"type": "black",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"target_value": 0.08,
"weight": 0.7,
"roi_pct_by_role": {
"rgb": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"re": {
"x0": 0.034375,
"y0": 0.83,
"x1": 0.132812,
"y1": 0.925
},
"nir": {
"x0": 0.0125,
"y0": 0.86,
"x1": 0.107813,
"y1": 0.95
}
}
},
"re": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 3.0
{
"name": "gray_reference",
"type": "gray",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"target_value": 0.42,
"target_value_by_role": {
"rgb": 0.42,
"re": 0.36,
"nir": 0.40
},
"weight": 1.0,
"roi_pct_by_role": {
"rgb": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"re": {
"x0": 0.5,
"y0": 0.83,
"x1": 0.590625,
"y1": 0.9225
},
"nir": {
"x0": 0.470313,
"y0": 0.86,
"x1": 0.565625,
"y1": 0.9625
}
}
},
"nir": {
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 3.0
{
"name": "white_reference",
"type": "white",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"target_value": 0.82,
"weight": 0.8,
"roi_pct_by_role": {
"rgb": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"re": {
"x0": 0.89375,
"y0": 0.8325,
"x1": 0.984375,
"y1": 0.93
},
"nir": {
"x0": 0.875,
"y0": 0.86,
"x1": 0.970313,
"y1": 0.96
}
}
}
},
],
"exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.05,
"apply_same_spectral_to_both": true,
"spectral_roles": [
"re",
"nir"
]
],
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.20,
"reduce_fast_factor": 0.60,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"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
}
},
"ready_required_cycles": 3,
"patch_control_mode": "gray_primary",
"patch_require_order": true,
"patch_min_separation": 0.08,
"patch_white_sat_limit_pct": 2.0,
"patch_white_p95_limit": 0.94,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.2,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85
},
"radiometric_normalization": {
"enabled": true,
"method": "exposure_gain_reference",
"apply_stage": "after_dark_before_flat_gain",
"reference_controls": {
"rgb": {
"exposure_time_us": 4800,
"analogue_gain": 1.0
},
"re": {
"exposure_time_us": 13200,
"analogue_gain": 1.0
},
"nir": {
"exposure_time_us": 13200,
"analogue_gain": 1.0
}
},
"clip_output": true
},
"patch_normalization": {
"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.4,
"white": 0.78
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.5,
"clip_output": true,
"require_valid_gray": true,
"use_black_for_offset": false,
"save_patch_stats": true
},
"rgb_calibration": {
"enabled": true,
"gains": {
"R": 1.0,
"R": 1.2500000000000002,
"G": 1.0,
"B": 1.0
"B": 1.5500000000000005
}
},
"flatfield_config": {
"enabled": true,
"subtract_dark": true,
"schema": "multispec_flatfield_v1",
"created_at": "2026-05-06 10:14:01",
"created_at": "2026-05-06 13:37:25",
"json_file": "calibration/flatfield_maps_v1.json",
"npz_file": "calibration/flatfield_maps_v1.npz",
"apply_before_fusion": true,
@ -179,10 +357,10 @@
400,
640
],
"gain_min": 0.5480560660362244,
"gain_max": 1.6087802648544312,
"gain_mean": 1.0039905309677124,
"gain_std": 0.28920215368270874
"gain_min": 0.5639018416404724,
"gain_max": 1.8886771202087402,
"gain_mean": 1.0681155920028687,
"gain_std": 0.3506295084953308
},
"G": {
"gain_key": "gain_G",
@ -193,10 +371,10 @@
400,
640
],
"gain_min": 0.5317091345787048,
"gain_max": 1.8951553106307983,
"gain_mean": 1.0328274965286255,
"gain_std": 0.3529214859008789
"gain_min": 0.5427238941192627,
"gain_max": 2.177884101867676,
"gain_mean": 1.0985527038574219,
"gain_std": 0.4235461354255676
},
"B": {
"gain_key": "gain_B",
@ -207,10 +385,10 @@
400,
640
],
"gain_min": 0.5656915903091431,
"gain_max": 1.8551995754241943,
"gain_mean": 1.0326939821243286,
"gain_std": 0.3330632746219635
"gain_min": 0.57332444190979,
"gain_max": 1.991808533668518,
"gain_mean": 1.0765669345855713,
"gain_std": 0.36871764063835144
},
"RE": {
"gain_key": "gain_RE",
@ -219,12 +397,12 @@
"dark_median_key": "dark_median_RE",
"shape": [
800,
1600
1280
],
"gain_min": 0.955564022064209,
"gain_max": 4.0,
"gain_mean": 1.2053359746932983,
"gain_std": 0.28413665294647217
"gain_min": 0.7719405889511108,
"gain_max": 1.6437114477157593,
"gain_mean": 1.0222759246826172,
"gain_std": 0.19225353002548218
},
"NIR": {
"gain_key": "gain_NIR",
@ -233,12 +411,12 @@
"dark_median_key": "dark_median_NIR",
"shape": [
800,
1600
1280
],
"gain_min": 0.8534727096557617,
"gain_max": 4.0,
"gain_mean": 1.146588921546936,
"gain_std": 0.4995245635509491
"gain_min": 0.808167576789856,
"gain_max": 1.5716955661773682,
"gain_mean": 1.021193265914917,
"gain_std": 0.1498267650604248
}
},
"exp_gain_correct_during_flat_capture": true,

View File

@ -51,7 +51,44 @@
"spectral_roles": [
"re",
"nir"
]
],
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.75,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"ready_required_cycles": 3,
"global_roi_pct_by_role": {
"rgb": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
},
"re": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
},
"nir": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
}
}
}
},
"three_reference_patches_mode": {
@ -83,13 +120,33 @@
"nir"
],
"roi_pct": {
"x0": 0.05,
"y0": 0.92,
"x1": 0.18,
"y1": 0.99
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"target_value": 0.08,
"weight": 0.7
"weight": 0.7,
"roi_pct_by_role": {
"rgb": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"re": {
"x0": 0.034375,
"y0": 0.83,
"x1": 0.132812,
"y1": 0.925
},
"nir": {
"x0": 0.0125,
"y0": 0.86,
"x1": 0.107813,
"y1": 0.95
}
}
},
{
"name": "gray_reference",
@ -100,13 +157,33 @@
"nir"
],
"roi_pct": {
"x0": 0.35,
"y0": 0.92,
"x1": 0.55,
"y1": 0.99
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"target_value": 0.5,
"weight": 1.0
"weight": 1.0,
"roi_pct_by_role": {
"rgb": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"re": {
"x0": 0.5,
"y0": 0.83,
"x1": 0.590625,
"y1": 0.9225
},
"nir": {
"x0": 0.470313,
"y0": 0.86,
"x1": 0.565625,
"y1": 0.9625
}
}
},
{
"name": "white_reference",
@ -117,13 +194,33 @@
"nir"
],
"roi_pct": {
"x0": 0.75,
"y0": 0.92,
"x1": 0.95,
"y1": 0.99
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"target_value": 0.82,
"weight": 0.8
"weight": 0.8,
"roi_pct_by_role": {
"rgb": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"re": {
"x0": 0.89375,
"y0": 0.8325,
"x1": 0.984375,
"y1": 0.93
},
"nir": {
"x0": 0.875,
"y0": 0.86,
"x1": 0.970313,
"y1": 0.96
}
}
}
],
"exp_apply_threshold_us": 80,
@ -132,7 +229,54 @@
"spectral_roles": [
"re",
"nir"
]
],
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.75,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"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
}
},
"ready_required_cycles": 3,
"patch_control_mode": "gray_primary",
"patch_require_order": true,
"patch_min_separation": 0.08,
"patch_white_sat_limit_pct": 0.5,
"patch_white_p95_limit": 0.9,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.2,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85
}
},
"legacy_patch_mode": {
@ -158,6 +302,212 @@
"verbose": true
}
},
"schema": "multispec_radiometric_config_profiles_v1",
"saved_at": "2026-05-06 09:58:16"
"schema": "multispec_radiometric_config_profiles_v3",
"saved_at": "2026-05-07 08:55:15",
"active_profile": "three_reference_patches_mode",
"patch_normalization": {
"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.4,
"white": 0.78
},
"white_guard_max": 0.92,
"scale_min": 0.35,
"scale_max": 2.5,
"clip_output": true,
"require_valid_gray": true,
"use_black_for_offset": false,
"save_patch_stats": true
},
"radiometric_config": {
"enabled": true,
"interval_s": 0.5,
"verbose": true,
"metering_mode": "reference_patches",
"spectral_control_mode": "shared",
"control_metric": "p50",
"target_value": 0.5,
"deadband": 0.035,
"p95_limit": 0.92,
"saturation_limit_pct": 0.5,
"alpha": 0.18,
"exp_step_gain": 0.55,
"prefer_exposure": true,
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"reference_patches": [
{
"name": "black_reference",
"type": "black",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"target_value": 0.08,
"weight": 0.7,
"roi_pct_by_role": {
"rgb": {
"x0": 0.015625,
"y0": 0.9025,
"x1": 0.1125,
"y1": 0.995
},
"re": {
"x0": 0.034375,
"y0": 0.83,
"x1": 0.132812,
"y1": 0.925
},
"nir": {
"x0": 0.0125,
"y0": 0.86,
"x1": 0.107813,
"y1": 0.95
}
}
},
{
"name": "gray_reference",
"type": "gray",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"target_value": 0.5,
"weight": 1.0,
"roi_pct_by_role": {
"rgb": {
"x0": 0.475,
"y0": 0.895,
"x1": 0.56875,
"y1": 0.995
},
"re": {
"x0": 0.5,
"y0": 0.83,
"x1": 0.590625,
"y1": 0.9225
},
"nir": {
"x0": 0.470313,
"y0": 0.86,
"x1": 0.565625,
"y1": 0.9625
}
}
},
{
"name": "white_reference",
"type": "white",
"roles": [
"rgb",
"re",
"nir"
],
"roi_pct": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"target_value": 0.82,
"weight": 0.8,
"roi_pct_by_role": {
"rgb": {
"x0": 0.870313,
"y0": 0.9025,
"x1": 0.9625,
"y1": 0.995
},
"re": {
"x0": 0.89375,
"y0": 0.8325,
"x1": 0.984375,
"y1": 0.93
},
"nir": {
"x0": 0.875,
"y0": 0.86,
"x1": 0.970313,
"y1": 0.96
}
}
}
],
"exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.05,
"apply_same_spectral_to_both": true,
"spectral_roles": [
"re",
"nir"
],
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.75,
"factor_min": 0.72,
"factor_max": 1.28,
"gain_return_enabled": true,
"gain_reduce_on_saturation": true,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.2,
"gain_step_down": 0.5,
"gain_hard_reset_on_saturation": false,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"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
}
},
"ready_required_cycles": 3,
"patch_control_mode": "gray_primary",
"patch_require_order": true,
"patch_min_separation": 0.08,
"patch_white_sat_limit_pct": 0.5,
"patch_white_p95_limit": 0.9,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.2,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85
}
}

View File

@ -1,6 +1,6 @@
{
"schema": "multispec_camera_params_v2",
"saved_at": "2026-05-06 09:26:40",
"saved_at": "2026-05-06 13:41:54",
"frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO",
@ -37,9 +37,9 @@
"rgb_calibration": {
"enabled": true,
"gains": {
"R": 1.0,
"R": 1.2500000000000002,
"G": 1.0,
"B": 1.0
"B": 1.5500000000000005
}
},
"rois": {

View File

@ -249,6 +249,12 @@ class OakFcc3Client:
return previews, meta
def get_last_patch_normalization_result(self):
try:
return self.core.last_patch_normalization_result
except Exception:
return None
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
return self.core.build_infer_tensor_from_stream(
frame,

View File

@ -61,16 +61,41 @@ class RadiometricController:
self.patch_x1_pct = float(cfg.get("patch_x1_pct", patch_x1_pct))
global_roi = cfg.get("global_roi_pct", {}) or {}
self.global_roi_pct = {
"x0": float(global_roi.get("x0", 0.08)),
"y0": float(global_roi.get("y0", 0.08)),
"x1": float(global_roi.get("x1", 0.92)),
"y1": float(global_roi.get("y1", 0.92)),
}
self.global_roi_pct = self._safe_roi_pct(
global_roi,
fallback={"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92},
)
raw_global_by_role = cfg.get("global_roi_pct_by_role", {}) or {}
self.global_roi_pct_by_role = {}
for role in self.ROLES:
roi = raw_global_by_role.get(role)
if isinstance(roi, dict) and roi:
self.global_roi_pct_by_role[role] = self._safe_roi_pct(
roi,
fallback=self.global_roi_pct,
)
else:
self.global_roi_pct_by_role[role] = dict(self.global_roi_pct)
self.reference_patches = cfg.get("reference_patches", []) or []
self.patch_aggregation = str(cfg.get("patch_aggregation", "weighted_mean")).lower()
self.patch_control_mode = str(cfg.get("patch_control_mode", "gray_primary")).lower()
self.patch_require_order = bool(cfg.get("patch_require_order", True))
self.patch_min_separation = float(cfg.get("patch_min_separation", 0.08))
self.patch_white_sat_limit_pct = float(cfg.get("patch_white_sat_limit_pct", 0.50))
self.patch_white_p95_limit = float(cfg.get("patch_white_p95_limit", 0.90))
self.patch_black_dark_limit_pct = float(cfg.get("patch_black_dark_limit_pct", 80.0))
self.patch_black_max_p50 = float(cfg.get("patch_black_max_p50", 0.20))
self.patch_gray_min_p50 = float(cfg.get("patch_gray_min_p50", 0.08))
self.patch_gray_max_p50 = float(cfg.get("patch_gray_max_p50", 0.85))
self.control_metric = str(cfg.get("control_metric", "p50")).lower()
self.target_value = float(cfg.get("target_value", cfg.get("target_mean", target_mean)))
self.target_mean = self.target_value
@ -85,6 +110,52 @@ class RadiometricController:
self.factor_min = float(cfg.get("factor_min", 0.72))
self.factor_max = float(cfg.get("factor_max", 1.28))
self.saturation_hard_pct = float(cfg.get("saturation_hard_pct", 20.0))
self.saturation_extreme_pct = float(cfg.get("saturation_extreme_pct", 60.0))
self.gain_return_enabled = bool(cfg.get("gain_return_enabled", True))
self.gain_return_factor = float(cfg.get("gain_return_factor", 0.60))
self.gain_reduce_on_saturation = bool(cfg.get("gain_reduce_on_saturation", True))
self.exp_high_ratio_for_gain = float(cfg.get("exp_high_ratio_for_gain", 0.85))
self.exp_low_ratio_for_gain_return = float(cfg.get("exp_low_ratio_for_gain_return", 0.65))
self.gain_increase_required_cycles = int(cfg.get("gain_increase_required_cycles", 5))
self.gain_decrease_required_cycles = int(cfg.get("gain_decrease_required_cycles", 2))
self.gain_step_up = float(cfg.get("gain_step_up", 0.25))
self.gain_step_down = float(cfg.get("gain_step_down", 0.50))
self.gain_hard_reset_on_saturation = bool(cfg.get("gain_hard_reset_on_saturation", False))
self._underexposed_cycles = {
"rgb": 0,
"re": 0,
"nir": 0,
"spectral_shared": 0,
}
self._overexposed_cycles = {
"rgb": 0,
"re": 0,
"nir": 0,
"spectral_shared": 0,
}
self.control_strategy = str(cfg.get("control_strategy", "ratio")).lower()
self.ratio_alpha = float(cfg.get("ratio_alpha", 0.55))
self.ratio_min = float(cfg.get("ratio_min", 0.55))
self.ratio_max = float(cfg.get("ratio_max", 1.85))
self.ready_required_cycles = int(cfg.get("ready_required_cycles", 3))
self._ready_cycles = {
"rgb": 0,
"re": 0,
"nir": 0,
"spectral_shared": 0,
}
self.exp_min_us = int(cfg.get("exp_min_us", exp_min_us))
self.exp_max_us = int(cfg.get("exp_max_us", exp_max_us))
self.gain_min = float(cfg.get("gain_min", gain_min))
@ -127,6 +198,60 @@ class RadiometricController:
cfg = data.get("radiometric_config", {})
return cfg if isinstance(cfg, dict) else {}
ROLES = ("rgb", "re", "nir")
@classmethod
def _normalize_role(cls, role: str) -> str:
role = str(role or "").lower()
return role if role in cls.ROLES else "rgb"
@staticmethod
def _safe_roi_pct(roi_pct, fallback=None) -> dict:
if fallback is None:
fallback = {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92}
if not isinstance(roi_pct, dict):
roi_pct = fallback
return {
"x0": float(roi_pct.get("x0", fallback.get("x0", 0.08))),
"y0": float(roi_pct.get("y0", fallback.get("y0", 0.08))),
"x1": float(roi_pct.get("x1", fallback.get("x1", 0.92))),
"y1": float(roi_pct.get("y1", fallback.get("y1", 0.92))),
}
def _get_global_roi_pct_for_role(self, role: str) -> dict:
role = self._normalize_role(role)
by_role = getattr(self, "global_roi_pct_by_role", {}) or {}
if isinstance(by_role, dict):
roi = by_role.get(role)
if isinstance(roi, dict) and roi:
return self._safe_roi_pct(roi, fallback=self.global_roi_pct)
return self._safe_roi_pct(self.global_roi_pct)
def _get_patch_roi_pct_for_role(self, patch: dict, role: str):
role = self._normalize_role(role)
by_role = patch.get("roi_pct_by_role", {})
if isinstance(by_role, dict):
roi = by_role.get(role)
if isinstance(roi, dict) and roi:
return self._safe_roi_pct(roi), "roi_pct_by_role"
legacy = patch.get("roi_pct")
if isinstance(legacy, dict) and legacy:
return self._safe_roi_pct(legacy), "roi_pct"
return None, "missing"
def get_patch_target_for_role(self, patch, role, fallback):
by_role = patch.get("target_value_by_role", {})
if isinstance(by_role, dict) and role in by_role:
return float(by_role[role])
return float(patch.get("target_value", fallback))
def sync_from_camera_controls(self, camera_controls: dict | None):
if not isinstance(camera_controls, dict):
return
@ -193,7 +318,7 @@ class RadiometricController:
metrics = self.measure_image(img, role=role)
decision = self.compute_control(role, metrics)
apply_resp = self.apply_control(role, decision)
return {
result = {
"mode": "single_role",
"cam_id": cam_id,
"role": role,
@ -202,6 +327,9 @@ class RadiometricController:
"decision": decision,
"apply": apply_resp,
}
self._print_metrics_debug(role, result)
return result
def _update_spectral_shared(self, decoded: dict):
role_items = {}
@ -230,7 +358,7 @@ class RadiometricController:
else:
apply_resp[state_role] = self.apply_control(state_role, decision)
return {
result = {
"mode": "shared_spectral",
"roles": role_items,
"metering_mode": self.metering_mode,
@ -238,6 +366,70 @@ class RadiometricController:
"decision": decision,
"apply": apply_resp,
}
self._print_metrics_debug("spectral_shared", result)
return result
def _update_ready_state(
self,
log_role: str,
action: str,
error: float,
p95: float,
sat_pct: float,
) -> tuple[bool, int]:
key = str(log_role).lower()
is_ready_now = (
action == "hold"
and abs(float(error)) <= self.deadband
and float(p95) <= self.p95_limit
and float(sat_pct) <= self.saturation_limit_pct
)
if is_ready_now:
self._ready_cycles[key] = self._ready_cycles.get(key, 0) + 1
else:
self._ready_cycles[key] = 0
cycles = self._ready_cycles.get(key, 0)
return cycles >= self.ready_required_cycles, cycles
def _update_exposure_pressure_state(
self,
log_role: str,
error: float,
p95: float,
sat_pct: float,
) -> tuple[int, int]:
key = str(log_role).lower()
under = (
error > self.deadband
and p95 < self.p95_limit
and sat_pct <= self.saturation_limit_pct
)
over = (
error < -self.deadband
or p95 > self.p95_limit
or sat_pct > self.saturation_limit_pct
)
if under:
self._underexposed_cycles[key] = self._underexposed_cycles.get(key, 0) + 1
else:
self._underexposed_cycles[key] = 0
if over:
self._overexposed_cycles[key] = self._overexposed_cycles.get(key, 0) + 1
else:
self._overexposed_cycles[key] = 0
return (
self._underexposed_cycles.get(key, 0),
self._overexposed_cycles.get(key, 0),
)
def _resolve_cam_id(self, decoded, role):
role = str(role).lower()
@ -247,12 +439,16 @@ class RadiometricController:
return None
def measure_image(self, img01: np.ndarray, role: str) -> dict:
role = self._normalize_role(role)
gray = self.to_luma_or_gray(img01)
if self.metering_mode == "reference_patches":
return self.measure_reference_patches(gray, role=role)
if self.metering_mode == "legacy_patch":
return self.measure_legacy_patch(gray)
return self.measure_global(gray)
return self.measure_global(gray, role=role)
@staticmethod
def to_luma_or_gray(img01: np.ndarray) -> np.ndarray:
@ -264,19 +460,29 @@ class RadiometricController:
).astype(np.float32)
return img01.astype(np.float32)
def measure_global(self, img_gray: np.ndarray) -> dict:
def measure_global(self, img_gray: np.ndarray, role: str = "rgb") -> dict:
role = self._normalize_role(role)
h, w = img_gray.shape[:2]
roi_pct = self._get_global_roi_pct_for_role(role)
roi = self._roi_pct_to_pixels(
h, w,
self.global_roi_pct["x0"],
self.global_roi_pct["y0"],
self.global_roi_pct["x1"],
self.global_roi_pct["y1"],
roi_pct["x0"],
roi_pct["y0"],
roi_pct["x1"],
roi_pct["y1"],
)
arr = self._crop_array(img_gray, roi)
stats = self.compute_stats(arr)
stats["roi"] = list(roi)
stats["roi_pct"] = dict(roi_pct)
stats["roi_source"] = "global_roi_pct_by_role"
stats["role"] = role
stats["source"] = "global"
return stats
def measure_legacy_patch(self, img_gray: np.ndarray) -> dict:
@ -295,46 +501,69 @@ class RadiometricController:
return stats
def measure_reference_patches(self, img_gray: np.ndarray, role: str) -> dict:
role = self._normalize_role(role)
h, w = img_gray.shape[:2]
patch_results = []
for patch in self.reference_patches:
if not isinstance(patch, dict):
continue
roles = patch.get("roles", ["rgb", "re", "nir", "all"])
roles = [str(r).lower() for r in roles]
if role not in roles and "all" not in roles:
continue
roi_pct = patch.get("roi_pct")
roi_pct, roi_source = self._get_patch_roi_pct_for_role(patch, role)
if not isinstance(roi_pct, dict):
continue
x0 = float(roi_pct.get("x0", 0.0))
y0 = float(roi_pct.get("y0", 0.0))
x1 = float(roi_pct.get("x1", 1.0))
y1 = float(roi_pct.get("y1", 1.0))
roi = self._roi_pct_to_pixels(h, w, x0, y0, x1, y1)
arr = self._crop_array(img_gray, roi)
stats = self.compute_stats(arr)
target = patch.get("target_value", patch.get("target_mean", None))
target = self.get_patch_target_for_role(
patch=patch,
role=role,
fallback=self.target_value,
)
if target is not None:
target = float(target)
patch_results.append({
"name": patch.get("name", f"patch_{len(patch_results)+1}"),
"name": patch.get("name", f"patch_{len(patch_results) + 1}"),
"type": patch.get("type", "reference"),
"role": role,
"roi": list(roi),
"roi_pct": {"x0": x0, "y0": y0, "x1": x1, "y1": y1},
"roi_source": roi_source,
"weight": float(patch.get("weight", 1.0)),
"target_value": target,
"stats": stats,
})
if not patch_results:
stats = self.measure_global(img_gray)
stats = self.measure_global(img_gray, role=role)
stats["source"] = "reference_patches_fallback_global"
stats["patches"] = []
return stats
return self.aggregate_patch_metrics(patch_results)
metrics = self.aggregate_patch_metrics(patch_results)
metrics["role"] = role
return metrics
def aggregate_patch_metrics(self, patch_results: list[dict]) -> dict:
valid = [p for p in patch_results if p["stats"].get("valid")]
if not valid:
return {
"valid": False,
@ -347,45 +576,172 @@ class RadiometricController:
"dark_pct": 0.0,
"control_value": 0.0,
"target_value": self.target_value,
"weighted_error": 0.0,
"patch_quality": {
"valid": False,
"warnings": ["no_valid_patches"],
},
}
weights = np.array([max(0.0, p.get("weight", 1.0)) for p in valid], dtype=np.float32)
if float(weights.sum()) <= 1e-9:
weights = np.ones(len(valid), dtype=np.float32)
weights = weights / weights.sum()
black = self._find_patch_result(valid, "black")
gray = self._find_patch_result(valid, "gray")
white = self._find_patch_result(valid, "white")
warnings = []
# Stats gerais de proteção.
p95s = np.array([p["stats"]["p95"] for p in valid], dtype=np.float32)
sats = np.array([p["stats"]["sat_pct"] for p in valid], dtype=np.float32)
darks = np.array([p["stats"]["dark_pct"] for p in valid], dtype=np.float32)
means = np.array([p["stats"]["mean"] for p in valid], dtype=np.float32)
p50s = np.array([p["stats"]["p50"] for p in valid], dtype=np.float32)
p95s = np.array([p["stats"]["p95"] for p in valid], dtype=np.float32)
sat = np.array([p["stats"]["sat_pct"] for p in valid], dtype=np.float32)
dark = np.array([p["stats"]["dark_pct"] for p in valid], dtype=np.float32)
patch_errors = []
control_values = []
for p in valid:
target = p.get("target_value")
if target is None:
target = self.target_value
value = p["stats"].get(self.control_metric, p["stats"].get("p50", p["stats"].get("mean", 0.0)))
control_values.append(float(value))
patch_errors.append(float(target) - float(value))
p95_max = float(np.max(p95s))
sat_max = float(np.max(sats))
dark_mean = float(np.mean(darks))
mean_mean = float(np.mean(means))
p50_mean = float(np.mean(p50s))
weighted_error = float(np.sum(np.array(patch_errors, dtype=np.float32) * weights))
control_value = float(np.sum(np.array(control_values, dtype=np.float32) * weights))
# Valores por patch, quando existem.
black_p50 = float(black["stats"]["p50"]) if black else None
gray_p50 = float(gray["stats"]["p50"]) if gray else None
white_p50 = float(white["stats"]["p50"]) if white else None
black_target = float(black.get("target_value", 0.06)) if black else 0.06
gray_target = float(gray.get("target_value", self.target_value)) if gray else self.target_value
white_target = float(white.get("target_value", 0.80)) if white else 0.80
# ============================================================
# Validações de coerência dos cartões
# ============================================================
if gray is None:
warnings.append("missing_gray_patch")
if self.patch_require_order and black and gray and white:
if not (black_p50 < gray_p50 < white_p50):
warnings.append(
f"patch_order_invalid: black={black_p50:.3f}, gray={gray_p50:.3f}, white={white_p50:.3f}"
)
if (gray_p50 - black_p50) < self.patch_min_separation:
warnings.append(
f"black_gray_separation_low: diff={gray_p50 - black_p50:.3f}"
)
if (white_p50 - gray_p50) < self.patch_min_separation:
warnings.append(
f"gray_white_separation_low: diff={white_p50 - gray_p50:.3f}"
)
if white:
white_sat = float(white["stats"]["sat_pct"])
white_p95 = float(white["stats"]["p95"])
if white_sat > self.patch_white_sat_limit_pct:
warnings.append(f"white_patch_saturated: sat={white_sat:.2f}%")
if white_p95 > self.patch_white_p95_limit:
warnings.append(f"white_patch_p95_high: p95={white_p95:.3f}")
if black:
black_dark = float(black["stats"]["dark_pct"])
if black_dark > self.patch_black_dark_limit_pct:
warnings.append(f"black_patch_too_dark: dark={black_dark:.1f}%")
if black_p50 > self.patch_black_max_p50:
warnings.append(f"black_patch_too_bright: p50={black_p50:.3f}")
if gray:
if gray_p50 < self.patch_gray_min_p50:
warnings.append(f"gray_patch_too_dark: p50={gray_p50:.3f}")
if gray_p50 > self.patch_gray_max_p50:
warnings.append(f"gray_patch_too_bright: p50={gray_p50:.3f}")
# ============================================================
# Modo recomendado: cinza como controle principal
# ============================================================
if self.patch_control_mode == "gray_primary" and gray is not None:
control_value = gray_p50
target_value = gray_target
weighted_error = target_value - control_value
control_source = "gray_primary"
else:
# Fallback: média ponderada original, mas preservando guardas.
weights = np.array([max(0.0, p.get("weight", 1.0)) for p in valid], dtype=np.float32)
if float(weights.sum()) <= 1e-9:
weights = np.ones(len(valid), dtype=np.float32)
weights = weights / weights.sum()
patch_errors = []
control_values = []
for p in valid:
target = p.get("target_value")
if target is None:
target = self.target_value
value = p["stats"].get(
self.control_metric,
p["stats"].get("p50", p["stats"].get("mean", 0.0))
)
control_values.append(float(value))
patch_errors.append(float(target) - float(value))
weighted_error = float(np.sum(np.array(patch_errors, dtype=np.float32) * weights))
control_value = float(np.sum(np.array(control_values, dtype=np.float32) * weights))
target_value = self.target_value
control_source = "weighted_patches"
# ============================================================
# Guardas de saturação e faixa útil
# ============================================================
# Se o branco saturou, queremos que o compute_control reduza exposição,
# mesmo que o cinza esteja aparentemente bom.
if white:
white_sat = float(white["stats"]["sat_pct"])
white_p95 = float(white["stats"]["p95"])
sat_max = max(sat_max, white_sat)
p95_max = max(p95_max, white_p95)
# Se o cinza está ausente, a métrica ainda pode funcionar por fallback,
# mas marcamos warning para debug.
quality_valid = gray is not None and len(warnings) == 0
return {
"valid": True,
"source": "reference_patches",
"patches": patch_results,
"mean": float(np.sum(means * weights)),
"p50": float(np.sum(p50s * weights)),
"p95": float(np.max(p95s)),
"sat_pct": float(np.max(sat)),
"dark_pct": float(np.sum(dark * weights)),
# Métricas agregadas informativas.
"mean": mean_mean,
"p50": p50_mean,
"p95": p95_max,
"sat_pct": sat_max,
"dark_pct": dark_mean,
# Métricas usadas pelo controle.
"control_metric": self.control_metric,
"control_value": control_value,
"target_value": self.target_value,
"weighted_error": weighted_error,
"control_value": float(control_value),
"target_value": float(target_value),
"weighted_error": float(weighted_error),
# Debug/qualidade.
"patch_control_mode": self.patch_control_mode,
"control_source": control_source,
"patch_quality": {
"valid": bool(quality_valid),
"warnings": warnings,
"black_p50": black_p50,
"gray_p50": gray_p50,
"white_p50": white_p50,
"black_target": black_target,
"gray_target": gray_target,
"white_target": white_target,
},
}
def aggregate_spectral_metrics(self, role_items: dict) -> dict:
@ -411,11 +767,23 @@ class RadiometricController:
p50 = float(np.mean([float(m.get("p50", m.get("mean", 0.0))) for m in metrics_list]))
dark_pct = float(np.mean([float(m.get("dark_pct", 0.0)) for m in metrics_list]))
control_values = [
float(m.get(self.control_metric, m.get("control_value", m.get("p50", m.get("mean", 0.0)))))
float(m.get("control_value", m.get(self.control_metric, m.get("p50", m.get("mean", 0.0)))))
for m in metrics_list
]
target_values = [
float(m.get("target_value", self.target_value))
for m in metrics_list
]
errors = [
float(m.get("weighted_error", target - value))
for m, target, value in zip(metrics_list, target_values, control_values)
]
control_value = float(np.mean(control_values))
weighted_error = self.target_value - control_value
target_value = float(np.mean(target_values))
weighted_error = float(np.mean(errors))
return {
"valid": True,
@ -428,8 +796,20 @@ class RadiometricController:
"dark_pct": dark_pct,
"control_metric": self.control_metric,
"control_value": control_value,
"target_value": self.target_value,
"target_value": target_value,
"weighted_error": weighted_error,
"control_values_by_role": {
role: float(item["metrics"].get("control_value", item["metrics"].get("p50", 0.0)))
for role, item in valid_items.items()
},
"targets_by_role": {
role: float(item["metrics"].get("target_value", self.target_value))
for role, item in valid_items.items()
},
"patch_quality_by_role": {
role: item["metrics"].get("patch_quality", {})
for role, item in valid_items.items()
},
}
def compute_stats(self, arr: np.ndarray) -> dict:
@ -475,15 +855,31 @@ class RadiometricController:
y1 = max(y0 + 1, min(h, y1))
return x0, y0, x1, y1
@staticmethod
def _find_patch_result(patch_results: list[dict], patch_type: str):
patch_type = str(patch_type).lower()
for p in patch_results:
if str(p.get("type", "")).lower() == patch_type:
return p
return None
def compute_control(self, role: str, metrics: dict, virtual_role: str | None = None) -> dict:
state_role = str(role).lower()
log_role = virtual_role or state_role
st = self.state.setdefault(state_role, {"exp": 15000, "gain": 1.0})
old_exp = int(st["exp"])
old_gain = float(st["gain"])
limits = self._limits_for_role(state_role)
if not metrics.get("valid"):
ready, ready_cycles = self._update_ready_state(
log_role=log_role,
action="hold",
error=999.0,
p95=1.0,
sat_pct=100.0,
)
return {
"role": log_role,
"state_role": state_role,
@ -493,46 +889,189 @@ class RadiometricController:
"new_exp": old_exp,
"old_gain": old_gain,
"new_gain": old_gain,
"ready": ready,
"ready_cycles": ready_cycles,
"ready_required_cycles": self.ready_required_cycles,
}
control_value = float(metrics.get("control_value", metrics.get(self.control_metric, metrics.get("p50", metrics.get("mean", 0.0)))))
control_value = float(metrics.get(
"control_value",
metrics.get(self.control_metric, metrics.get("p50", metrics.get("mean", 0.0)))
))
target = float(metrics.get("target_value", self.target_value))
error = float(metrics.get("weighted_error", target - control_value))
p95 = float(metrics.get("p95", 0.0))
sat_pct = float(metrics.get("sat_pct", 0.0))
under_cycles, over_cycles = self._update_exposure_pressure_state(
log_role=log_role,
error=error,
p95=p95,
sat_pct=sat_pct,
)
exp_min = int(limits["exp_min_us"])
exp_max = int(limits["exp_max_us"])
gain_min = float(limits["gain_min"])
gain_max = float(limits["gain_max"])
new_exp = old_exp
new_gain = old_gain
action = "hold"
reason = "dentro da faixa morta"
ratio = None
factor = 1.0
gain_policy = "hold"
# ============================================================
# 1) Proteção forte contra saturação / p95 alto
# ============================================================
if sat_pct > self.saturation_limit_pct or p95 > self.p95_limit:
desired_exp = max(limits["exp_min_us"], int(old_exp * self.reduce_fast_factor))
new_exp = self._smooth_int(old_exp, desired_exp)
if sat_pct >= self.saturation_extreme_pct:
exp_factor = 0.45
elif sat_pct >= self.saturation_hard_pct:
exp_factor = 0.32
elif sat_pct > self.saturation_limit_pct:
exp_factor = 0.55
else:
exp_factor = self.reduce_fast_factor
new_exp = int(self._clamp(old_exp * exp_factor, exp_min, exp_max))
if self.gain_reduce_on_saturation and old_gain > gain_min:
if self.gain_hard_reset_on_saturation and sat_pct >= self.saturation_extreme_pct:
new_gain = gain_min
gain_policy = "hard_reset_gain_on_extreme_saturation"
else:
desired_gain = old_gain - self.gain_step_down
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
gain_policy = "decrease_gain_step_on_saturation"
else:
new_gain = old_gain
gain_policy = "hold_gain"
action = "decrease_exposure"
reason = f"saturação/p95 alto: sat={sat_pct:.2f}% p95={p95:.3f}"
reason = (
f"saturação/p95 alto: sat={sat_pct:.2f}% p95={p95:.3f} "
f"exp_factor={exp_factor:.3f} gain_policy={gain_policy}"
)
# ============================================================
# 2) Fora da faixa morta: controle por ratio/linear
# ============================================================
elif abs(error) > self.deadband:
factor = 1.0 + self.exp_step_gain * error
factor = max(self.factor_min, min(self.factor_max, factor))
if self.control_strategy == "ratio":
safe_value = max(control_value, 1e-6)
ratio = target / safe_value
ratio = self._clamp(ratio, self.ratio_min, self.ratio_max)
factor = 1.0 + self.ratio_alpha * (ratio - 1.0)
else:
factor = 1.0 + self.exp_step_gain * error
factor = max(self.factor_min, min(self.factor_max, factor))
if self.prefer_exposure:
desired_exp = int(old_exp * factor)
desired_exp = self._clamp(desired_exp, limits["exp_min_us"], limits["exp_max_us"])
new_exp = self._smooth_int(old_exp, desired_exp)
if desired_exp in (limits["exp_min_us"], limits["exp_max_us"]):
desired_gain = old_gain * factor
desired_gain = self._clamp(desired_gain, limits["gain_min"], limits["gain_max"])
new_gain = self._smooth_float(old_gain, desired_gain)
action = "increase_exposure" if error > 0 else "decrease_exposure"
reason = f"corrigindo {self.control_metric}: value={control_value:.3f} target={target:.3f} error={error:.3f}"
# ----------------------------------------------------
# 2A) Cena escura: subir exposição primeiro.
# Só subir ganho se exposição já estiver perto do máximo.
# ----------------------------------------------------
if error > 0:
desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max))
new_exp = desired_exp
new_gain = old_gain
gain_policy = "hold_gain_prefer_exposure"
exp_high_threshold = int(exp_max * self.exp_high_ratio_for_gain)
if (
desired_exp >= exp_high_threshold
and under_cycles >= self.gain_increase_required_cycles
):
# Sobe ganho devagar, em degrau fixo.
desired_gain = old_gain + self.gain_step_up
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
gain_policy = f"increase_gain_slow_under_cycles_{under_cycles}"
else:
new_gain = old_gain
gain_policy = f"hold_gain_under_cycles_{under_cycles}"
action = "increase_exposure"
reason = (
f"subindo exposição por {self.control_metric}: "
f"value={control_value:.3f} target={target:.3f} "
f"error={error:.3f} factor={factor:.3f} gain_policy={gain_policy}"
)
# ----------------------------------------------------
# 2B) Cena clara: se ganho está acima do mínimo,
# reduzir ganho primeiro ou junto.
# ----------------------------------------------------
else:
desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max))
new_exp = desired_exp
if (
self.gain_return_enabled
and old_gain > gain_min
and over_cycles >= self.gain_decrease_required_cycles
):
desired_gain = old_gain - self.gain_step_down
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
gain_policy = f"return_gain_step_over_cycles_{over_cycles}"
else:
new_gain = old_gain
gain_policy = f"hold_gain_over_cycles_{over_cycles}"
action = "decrease_exposure"
reason = (
f"reduzindo brilho por {self.control_metric}: "
f"value={control_value:.3f} target={target:.3f} "
f"error={error:.3f} factor={factor:.3f} gain_policy={gain_policy}"
)
else:
desired_gain = old_gain * factor
desired_gain = self._clamp(desired_gain, limits["gain_min"], limits["gain_max"])
new_gain = self._smooth_float(old_gain, desired_gain)
new_gain = float(self._clamp(desired_gain, gain_min, gain_max))
action = "increase_gain" if error > 0 else "decrease_gain"
reason = f"corrigindo ganho por {self.control_metric}: value={control_value:.3f} target={target:.3f} error={error:.3f}"
gain_policy = "direct_gain_control"
reason = (
f"corrigindo ganho por {self.control_metric}: "
f"value={control_value:.3f} target={target:.3f} "
f"error={error:.3f} factor={factor:.3f}"
)
new_exp = int(self._clamp(new_exp, limits["exp_min_us"], limits["exp_max_us"]))
new_gain = float(self._clamp(new_gain, limits["gain_min"], limits["gain_max"]))
# ============================================================
# 3) Dentro da faixa morta: opcionalmente devolver ganho
# se ganho alto não é mais necessário.
# ============================================================
else:
if self.gain_return_enabled and old_gain > gain_min:
exp_low_threshold = int(exp_max * self.exp_low_ratio_for_gain_return)
if old_exp < exp_low_threshold:
new_gain = float(self._clamp(old_gain * self.gain_return_factor, gain_min, gain_max))
gain_policy = "return_gain_while_ready"
action = "decrease_gain"
reason = (
f"dentro da faixa, devolvendo ganho: "
f"value={control_value:.3f} target={target:.3f} "
f"gain={old_gain:.2f}->{new_gain:.2f}"
)
else:
gain_policy = "hold_gain_high_exp"
else:
gain_policy = "hold_gain"
new_exp = int(self._clamp(new_exp, exp_min, exp_max))
new_gain = float(self._clamp(new_gain, gain_min, gain_max))
ready, ready_cycles = self._update_ready_state(
log_role=log_role,
action=action,
error=error,
p95=p95,
sat_pct=sat_pct,
)
return {
"role": log_role,
@ -547,11 +1086,25 @@ class RadiometricController:
"error": error,
"p95": p95,
"sat_pct": sat_pct,
#"metrics_source": metrics.get("source"),
#"control_source": metrics.get("control_source"),
#"patch_quality": metrics.get("patch_quality"),
#"patches": metrics.get("patches"),
#"control_values_by_role": metrics.get("control_values_by_role"),
#"targets_by_role": metrics.get("targets_by_role"),
#"patch_quality_by_role": metrics.get("patch_quality_by_role"),
"old_exp": old_exp,
"new_exp": new_exp,
"old_gain": old_gain,
"new_gain": new_gain,
"limits": limits,
"ready": ready,
"ready_cycles": ready_cycles,
"ready_required_cycles": self.ready_required_cycles,
"control_strategy": self.control_strategy,
"ratio": ratio,
"factor": float(factor),
"gain_policy": gain_policy,
}
def apply_control(self, role: str, decision: dict):
@ -577,7 +1130,13 @@ class RadiometricController:
except Exception as e:
responses["error"] = str(e)
if self.verbose:
print(f"[RAD] {role}: {json.dumps(decision, ensure_ascii=False)} | apply={responses}")
print(
f"[RAD_APPLY] role={role} "
f"action={decision.get('action')} "
f"exp={decision.get('old_exp')}->{decision.get('new_exp')} "
f"gain={decision.get('old_gain'):.2f}->{decision.get('new_gain'):.2f} "
f"ok={'error' not in responses}"
)
return responses
def _limits_for_role(self, role: str) -> dict:
@ -598,3 +1157,67 @@ class RadiometricController:
@staticmethod
def _clamp(v, lo, hi):
return max(lo, min(hi, v))
def _print_metrics_debug(self, role: str, result: dict):
if not self.verbose:
return
metrics = result.get("metrics", {})
decision = result.get("decision", {})
print(
f"[RAD_METRICS] role={role} "
f"mode={result.get('mode')} "
f"metering={result.get('metering_mode')} "
f"action={decision.get('action')} "
f"exp={decision.get('old_exp')}->{decision.get('new_exp')} "
f"gain={decision.get('old_gain')}->{decision.get('new_gain')} "
f"control={decision.get('control_value'):.3f} "
f"target={decision.get('target_value'):.3f} "
f"p95={decision.get('p95'):.3f} "
f"sat={decision.get('sat_pct'):.2f}%"
)
# Caso normal: rgb individual
patches = metrics.get("patches", [])
if patches:
for p in patches:
st = p.get("stats", {})
print(
f" [PATCH] role={p.get('role', role)} "
f"type={p.get('type')} "
f"roi_source={p.get('roi_source')} "
f"roi_pct={p.get('roi_pct')} "
f"p50={st.get('p50', 0):.3f} "
f"p95={st.get('p95', 0):.3f} "
f"sat={st.get('sat_pct', 0):.2f}% "
f"dark={st.get('dark_pct', 0):.1f}%"
)
# Caso spectral_shared: RE/NIR agregados
roles = metrics.get("roles", {})
if roles:
for r, item in roles.items():
m = item.get("metrics", {})
print(
f" [ROLE_METRICS] role={r} "
f"cam_id={item.get('cam_id')} "
f"control={m.get('control_value', 0):.3f} "
f"target={m.get('target_value', 0):.3f} "
f"p95={m.get('p95', 0):.3f} "
f"sat={m.get('sat_pct', 0):.2f}% "
f"warnings={m.get('patch_quality', {}).get('warnings', [])}"
)
for p in m.get("patches", []):
st = p.get("stats", {})
print(
f" [PATCH] role={p.get('role', r)} "
f"type={p.get('type')} "
f"roi_source={p.get('roi_source')} "
f"roi_pct={p.get('roi_pct')} "
f"p50={st.get('p50', 0):.3f} "
f"p95={st.get('p95', 0):.3f} "
f"sat={st.get('sat_pct', 0):.2f}% "
f"dark={st.get('dark_pct', 0):.1f}%"
)

View File

@ -61,10 +61,31 @@ class RawProcessorCore:
"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,
@ -260,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 pronto
- MULTISPEC uint8/float32 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")
@ -377,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}")
@ -532,13 +454,8 @@ class RawProcessorCore:
return np.clip(arr01, 0.0, 1.0)
def fuse_multispec_cameras(self, decoded, meta, channels_expected):
# 1) Coloca todos os frames na mesma escala de exposição/ganho de referência
decoded = self.normalize_decoded_by_capture_controls(decoded, meta)
# 2) Subtrai dark/offset no espaço individual de cada câmera
decoded = self.apply_dark_to_decoded(decoded)
# 3) Aplica o ganho espacial do flat field no espaço individual de cada câmera
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")
@ -751,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", {})
@ -919,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}")
@ -945,10 +1074,18 @@ class RawProcessorCore:
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)
@ -1312,22 +1449,18 @@ class RawProcessorCore:
if not meta:
return {}
# Preferência: controles reais daquele frame.
controls = meta.get("actual_camera_controls")
if isinstance(controls, dict) and controls:
return controls
for key in ("actual_camera_controls", "camera_controls", "startup_camera_controls"):
controls = meta.get(key)
if isinstance(controls, dict) and controls:
return controls
# Possíveis nomes alternativos.
controls = meta.get("camera_controls")
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
controls = meta.get("startup_camera_controls")
if isinstance(controls, dict) and controls:
return controls
# Em alguns casos o JSON da captura pode ter stream_meta separado,
# mas se o meta recebido aqui for só stream_meta, talvez não tenha controles.
return {}
def _exposure_gain_factor(self, ctrl: dict) -> float:

View File

@ -67,6 +67,7 @@ def build_flatfield_config(flatfield_json_path, flatfield_data):
return {
"enabled": True,
"subtract_dark": True,
"schema": flatfield_data.get("schema", "multispec_flatfield_v1"),
"created_at": flatfield_data.get("created_at"),
"json_file": rel_or_abs(flatfield_json_path),
@ -86,6 +87,31 @@ def build_flatfield_config(flatfield_json_path, flatfield_data):
}
def pick_radiometric_config(radiometric_data: dict, selected_profile: str | None = None):
if not isinstance(radiometric_data, dict):
return None
# 1) Novo contrato: usa radiometric_config da raiz se existir.
root_cfg = radiometric_data.get("radiometric_config")
if isinstance(root_cfg, dict):
return root_cfg
# 2) Usa active_profile se existir.
active_profile = radiometric_data.get("active_profile")
if active_profile in ("global_scene_mode", "three_reference_patches_mode"):
cfg = radiometric_data.get(active_profile, {}).get("radiometric_config")
if isinstance(cfg, dict):
return cfg
# 3) Fallback explícito por argumento.
if selected_profile:
cfg = radiometric_data.get(selected_profile, {}).get("radiometric_config")
if isinstance(cfg, dict):
return cfg
return None
def main():
parser = argparse.ArgumentParser(
description="Monta o module_params.json unificando calibração de câmera, fusão, radiometria e flat-field.",
@ -147,7 +173,7 @@ def main():
# =========================
# RADIOMETRIC
# =========================
radiometric_config = radiometric_data.get(args.radiometric_profile, {}).get("radiometric_config")
radiometric_config = pick_radiometric_config(radiometric_data, selected_profile=args.radiometric_profile)
if not isinstance(radiometric_config, dict):
radiometric_config = cam_data.get("radiometric_config")
@ -155,30 +181,104 @@ def main():
if not isinstance(radiometric_config, dict):
radiometric_config = {
"enabled": True,
"interval_s": 0.5,
"interval_s": 0.20,
"verbose": True,
"metering_mode": "global",
"spectral_control_mode": "shared",
"global_roi_pct": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92
},
"control_metric": "p50",
"target_value": 0.40,
"deadband": 0.04,
"p95_limit": 0.94,
"saturation_limit_pct": 1.0,
"alpha": 0.18,
"exp_step_gain": 0.55,
"p95_limit": 0.90,
"saturation_limit_pct": 0.50,
"saturation_hard_pct": 10.0,
"saturation_extreme_pct": 50.0,
"dark_limit_pct": 35.0,
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"reduce_fast_factor": 0.70,
"gain_return_enabled": True,
"gain_return_factor": 0.50,
"gain_reduce_on_saturation": True,
"gain_hard_reset_on_saturation": False,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.20,
"gain_step_down": 0.50,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"prefer_exposure": True,
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"role_limits": {
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 4.0},
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
},
"exp_apply_threshold_us": 40,
"gain_apply_threshold": 0.03,
"ready_required_cycles": 3,
"apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"]
"spectral_roles": ["re", "nir"],
}
radiometric_normalization_config = radiometric_data.get("radiometric_normalization")
if not isinstance(radiometric_normalization_config, dict):
radiometric_normalization_config = cam_data.get("radiometric_normalization")
if not isinstance(radiometric_normalization_config, dict):
radiometric_normalization_config = {
"enabled": True,
"method": "exposure_gain_reference",
"apply_stage": "after_dark_before_flat_gain",
"reference_controls": {
"rgb": {"exposure_time_us": 3000, "analogue_gain": 1.0},
"re": {"exposure_time_us": 7000, "analogue_gain": 1.0},
"nir": {"exposure_time_us": 7000, "analogue_gain": 1.0},
},
"clip_output": True,
}
patch_normalization_config = radiometric_data.get("patch_normalization")
if not isinstance(patch_normalization_config, dict):
patch_normalization_config = cam_data.get("patch_normalization")
if not isinstance(patch_normalization_config, dict):
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,
}
# =========================
@ -216,6 +316,8 @@ def main():
"camera_settings": camera_settings,
"fusion_config": fusion_config,
"radiometric_config": radiometric_config,
"radiometric_normalization": radiometric_normalization_config,
"patch_normalization": patch_normalization_config,
"rgb_calibration": rgb_calibration,
"flatfield_config": flatfield_config,
}

View File

@ -2,6 +2,7 @@ import os
import json
import argparse
from pathlib import Path
from datetime import datetime
import cv2
import numpy as np
@ -15,6 +16,100 @@ def load_json(path: Path) -> dict:
return json.load(f)
def ts_name() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
def ensure_dir(path: Path | str):
Path(path).mkdir(parents=True, exist_ok=True)
def save_multispec_tensor_from_raw_group(
group: dict,
meta: dict,
out_dir: str = "calibration/offline_samples",
):
"""
Gera e salva um tensor MULTISPEC [5,H,W] float32 a partir de uma captura RAW_BRUTO.
Saídas:
.raw -> tensor float32 CHW
.json -> metadados do tensor gerado offline
.png -> preview RGB do tensor
"""
if meta.get("saved_payload_type") != "raw_native_multi":
raise RuntimeError("Só é possível gerar tensor offline a partir de saved_payload_type='raw_native_multi'.")
ensure_dir(out_dir)
out_dir = Path(out_dir)
tensor, desc = build_multispec_from_raw_native_multi(group, meta)
if tensor is None:
raise RuntimeError(f"Falha ao gerar tensor MULTISPEC: {desc}")
base_name = Path(group["json"]).stem
name = f"{base_name}_offline_multispec"
raw_path = out_dir / f"{name}.raw"
json_path = out_dir / f"{name}.json"
png_path = out_dir / f"{name}.png"
tensor = np.ascontiguousarray(tensor.astype(np.float32, copy=False))
tensor.tofile(str(raw_path))
# Preview RGB do tensor
rgb_hwc = np.transpose(tensor[:3], (1, 2, 0))
preview_bgr = normalize_float01_to_bgr(rgb_hwc)
cv2.imwrite(str(png_path), preview_bgr)
# JSON compatível com o validador e com análise posterior
out_meta = {
"ts": datetime.now().isoformat(timespec="milliseconds"),
"schema": "offline_multispec_from_raw_native_multi_v1",
"source_json": str(group["json"]),
"source_saved_payload_type": meta.get("saved_payload_type"),
"source_saved_payload_paths": meta.get("saved_payload_paths"),
"source_saved_payload_shapes": meta.get("saved_payload_shapes"),
"source_saved_payload_dtypes": meta.get("saved_payload_dtypes"),
"camera_params_json": meta.get("camera_params_json"),
"frame_type": "MULTISPEC",
"saved_payload_type": "multispec",
"saved_payload_path": raw_path.name,
"saved_payload_dtype": "float32",
"saved_payload_shape": list(tensor.shape),
"channels": ["R", "G", "B", "RE", "NIR"],
"saved_preview_path": png_path.name,
"generation": {
"method": "build_multispec_from_raw_native_multi",
"description": desc,
"same_frame_as_raw_bruto": True,
},
"source_capture_meta": {
"ts": meta.get("ts"),
"sensor_width": meta.get("sensor_width"),
"sensor_height": meta.get("sensor_height"),
"bayer_pattern": meta.get("bayer_pattern"),
"fps_target": meta.get("fps_target"),
"startup_camera_controls": meta.get("startup_camera_controls"),
"actual_camera_controls": meta.get("actual_camera_controls"),
"radiometric_last_result": meta.get("radiometric_last_result"),
"stream_meta": meta.get("stream_meta"),
},
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(out_meta, f, ensure_ascii=False, indent=2)
return {
"tensor": tensor,
"raw_path": raw_path,
"json_path": json_path,
"png_path": png_path,
"desc": desc,
}
def normalize_float01_to_bgr(img_float: np.ndarray) -> np.ndarray:
"""
Recebe RGB float32 [0..1] em HWC e devolve BGR uint8.
@ -657,6 +752,7 @@ def render_group_to_canvas(json_path: Path, max_width: int):
canvas = compose_panels(panels, max_width=max_width)
info = {
"group": group,
"json": group["json"],
"png": group["png"],
"final_raw": group["final_raw"],
@ -677,7 +773,7 @@ def main():
input_path = Path(args.input_path)
entries, current_idx = resolve_navigation_inputs(input_path)
window_name = "Validacao do payload salvo | A=anterior | D=proximo | Q/Esc=sair"
window_name = "Validacao payload | A=anterior | D=proximo | T=salva tensor offline | Q/Esc=sair"
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
while True:
@ -717,6 +813,25 @@ def main():
current_idx = min(current_idx + 1, len(entries) - 1)
elif k in (ord("a"), ord("A")):
current_idx = max(current_idx - 1, 0)
elif k in (ord("t"), ord("T")):
meta = info["meta"]
group = info["group"]
try:
result = save_multispec_tensor_from_raw_group(
group=group,
meta=meta,
out_dir="calibration/offline_samples",
)
print("[OK] Tensor MULTISPEC offline salvo:")
print(" RAW :", result["raw_path"])
print(" JSON:", result["json_path"])
print(" PNG :", result["png_path"])
print(" DESC:", result["desc"])
except Exception as e:
print("[ERRO] Falha ao salvar tensor MULTISPEC offline:", e)
cv2.destroyAllWindows()

View File

@ -172,9 +172,30 @@ def get_image_by_role(decoded: dict, role: str):
return cam_id, item.get("image")
def get_visual_preview_by_role(visual_previews: dict, meta: dict, role: str):
"""
Busca uma imagem visual BGR dentro do retorno de cam.build_visual_preview_from_raw(),
usando camera_info para descobrir o role rgb/re/nir.
Retorna: cam_id, img_bgr
"""
if not visual_previews:
return None, None
camera_info = (meta or {}).get("camera_info", {}) or {}
role = str(role).lower()
for cam_id, img in visual_previews.items():
cam_role = str(camera_info.get(cam_id, {}).get("role", "")).lower()
if cam_role == role:
return cam_id, img
return None, None
def validate_module_ready(status: dict, raw_policy: str):
if not status.get("ok", True):
raise RuntimeError(f"Status inválido retornado pelo módulo: {status}")
raise RuntimeError(f"Status inválido retornado pelo modulo: {status}")
active_roles = status.get("active_roles", {}) or {}
active_count = int(status.get("camera_count_active", 0))
@ -194,124 +215,174 @@ def validate_module_ready(status: dict, raw_policy: str):
# Config radiométrico
# ============================================================
def default_profile_global():
def base_ae_contract():
return {
"radiometric_config": {
"enabled": True,
"interval_s": 0.5,
"verbose": True,
"enabled": True,
"interval_s": 0.20,
"verbose": True,
"metering_mode": "global",
"spectral_control_mode": "shared",
"control_metric": "p50",
"target_value": 0.40,
"deadband": 0.04,
"global_roi_pct": {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92,
},
"p95_limit": 0.90,
"saturation_limit_pct": 0.50,
"dark_limit_pct": 35.0,
"control_metric": "p50",
"target_value": 0.40,
"deadband": 0.04,
# Novo controle proporcional por razão
"control_strategy": "ratio",
"ratio_alpha": 0.55,
"ratio_min": 0.55,
"ratio_max": 1.85,
"p95_limit": 0.94,
"saturation_limit_pct": 1.0,
"dark_limit_pct": 35.0,
# Redução rápida quando satura
"reduce_fast_factor": 0.75,
"alpha": 0.18,
"exp_step_gain": 0.55,
"prefer_exposure": True,
# Mantém compatibilidade com o modo antigo
"alpha": 0.18,
"exp_step_gain": 0.55,
"factor_min": 0.72,
"factor_max": 1.28,
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"prefer_exposure": True,
"role_limits": {
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 4.0},
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
},
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.05,
"gain_return_enabled": True,
"gain_reduce_on_saturation": True,
"gain_increase_required_cycles": 5,
"gain_decrease_required_cycles": 2,
"gain_step_up": 0.20,
"gain_step_down": 0.50,
"gain_hard_reset_on_saturation": False,
"exp_high_ratio_for_gain": 0.95,
"exp_low_ratio_for_gain_return": 0.75,
"apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"],
}
"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},
},
"exp_apply_threshold_us": 40,
"gain_apply_threshold": 0.03,
"ready_required_cycles": 3,
"apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"],
}
def default_profile_global():
cfg = base_ae_contract()
base = {
"x0": 0.08,
"y0": 0.08,
"x1": 0.92,
"y1": 0.92,
}
cfg.update({
"metering_mode": "global",
"spectral_control_mode": "shared",
"global_roi_pct": base,
"global_roi_pct_by_role": {
"rgb": dict(base),
"re": dict(base),
"nir": dict(base),
},
})
return {
"radiometric_config": cfg
}
def default_profile_patches():
return {
"radiometric_config": {
"enabled": True,
"interval_s": 0.5,
"verbose": True,
cfg = base_ae_contract()
cfg.update({
"metering_mode": "reference_patches",
"spectral_control_mode": "shared",
"deadband": 0.035,
"metering_mode": "reference_patches",
"spectral_control_mode": "shared",
"metering_mode": "reference_patches",
"patch_control_mode": "gray_primary",
"patch_require_order": True,
"patch_min_separation": 0.08,
"control_metric": "p50",
"target_value": 0.40,
"deadband": 0.035,
"patch_white_sat_limit_pct": 0.50,
"patch_white_p95_limit": 0.90,
"p95_limit": 0.94,
"saturation_limit_pct": 1.0,
"dark_limit_pct": 35.0,
"patch_black_dark_limit_pct": 80.0,
"patch_black_max_p50": 0.20,
"alpha": 0.18,
"exp_step_gain": 0.55,
"prefer_exposure": True,
"patch_gray_min_p50": 0.08,
"patch_gray_max_p50": 0.85,
"exp_min_us": 100,
"exp_max_us": 80000,
"gain_min": 1.0,
"gain_max": 4.0,
"role_limits": {
"rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 4.0},
"re": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
"nir": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 3.0},
"reference_patches": [
{
"name": "black_reference",
"type": "black",
"roles": ["rgb", "re", "nir"],
"target_value": 0.06,
"weight": 0.25,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
},
{
"name": "gray_reference",
"type": "gray",
"roles": ["rgb", "re", "nir"],
"target_value": 0.40,
"weight": 1.0,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
},
{
"name": "white_reference",
"type": "white",
"roles": ["rgb", "re", "nir"],
"target_value": 0.78,
"weight": 0.7,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
}
],
})
"reference_patches": [
{
"name": "black_reference",
"type": "black",
"roles": ["rgb", "re", "nir"],
"roi_pct": {"x0": 0.05, "y0": 0.92, "x1": 0.18, "y1": 0.99},
"target_value": 0.08,
"weight": 0.7,
},
{
"name": "gray_reference",
"type": "gray",
"roles": ["rgb", "re", "nir"],
"roi_pct": {"x0": 0.35, "y0": 0.92, "x1": 0.55, "y1": 0.99},
"target_value": 0.40,
"weight": 1.0,
},
{
"name": "white_reference",
"type": "white",
"roles": ["rgb", "re", "nir"],
"roi_pct": {"x0": 0.75, "y0": 0.92, "x1": 0.95, "y1": 0.99},
"target_value": 0.82,
"weight": 0.8,
},
],
"exp_apply_threshold_us": 80,
"gain_apply_threshold": 0.05,
"apply_same_spectral_to_both": True,
"spectral_roles": ["re", "nir"],
}
return {
"radiometric_config": cfg
}
def get_active_profile_name(data: dict) -> str:
name = str(data.get("active_profile", "global_scene_mode"))
if name not in ("global_scene_mode", "three_reference_patches_mode"):
return "global_scene_mode"
return name
def set_active_profile_name(data: dict, profile_name: str):
if profile_name not in ("global_scene_mode", "three_reference_patches_mode"):
profile_name = "global_scene_mode"
data["active_profile"] = profile_name
def get_active_radiometric_config(data: dict) -> dict:
profile_name = get_active_profile_name(data)
profile = data.get(profile_name, {}) or {}
cfg = profile.get("radiometric_config", {}) or {}
return json.loads(json.dumps(cfg))
def update_root_radiometric_config(data: dict):
data["radiometric_config"] = get_active_radiometric_config(data)
def load_or_default_config(path: str):
if path and os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
@ -319,36 +390,120 @@ def load_or_default_config(path: str):
else:
data = {}
data.setdefault("schema", "multispec_radiometric_config_profiles_v1")
data.setdefault("schema", "multispec_radiometric_config_profiles_v3")
data.setdefault("saved_at", now_str())
data.setdefault("active_profile", "global_scene_mode")
data.setdefault("global_scene_mode", default_profile_global())
data.setdefault("three_reference_patches_mode", default_profile_patches())
data.setdefault("patch_normalization", {
"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
},
"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
})
# Migração: se vier arquivo antigo sem contrato novo, injeta defaults novos
for profile_name, default_fn in (
("global_scene_mode", default_profile_global),
("three_reference_patches_mode", default_profile_patches),
):
default_profile = default_fn()
data.setdefault(profile_name, default_profile)
data[profile_name].setdefault("radiometric_config", {})
default_cfg = default_profile["radiometric_config"]
cfg = data[profile_name]["radiometric_config"]
for k, v in default_cfg.items():
cfg.setdefault(k, v)
update_root_radiometric_config(data)
return data
def save_config(path: str, data: dict):
ensure_dir(os.path.dirname(path) or ".")
data = dict(data)
data["schema"] = "multispec_radiometric_config_profiles_v1"
data["schema"] = "multispec_radiometric_config_profiles_v3"
data["saved_at"] = now_str()
update_root_radiometric_config(data)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_global_roi(data: dict):
return (
data.get("global_scene_mode", {})
.get("radiometric_config", {})
.get("global_roi_pct", {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92})
ROLES = ["rgb", "re", "nir"]
def normalize_role(role: str) -> str:
role = str(role or "rgb").lower()
return role if role in ROLES else "rgb"
def default_roi():
return {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92}
def clone_roi(roi: dict) -> dict:
roi = roi or {}
return {
"x0": float(roi.get("x0", 0.08)),
"y0": float(roi.get("y0", 0.08)),
"x1": float(roi.get("x1", 0.92)),
"y1": float(roi.get("y1", 0.92)),
}
def make_roi_by_role(base_roi=None):
base = clone_roi(base_roi or default_roi())
return {role: dict(base) for role in ROLES}
def ensure_global_roi_by_role(data: dict):
data.setdefault("global_scene_mode", default_profile_global())
cfg = data["global_scene_mode"].setdefault(
"radiometric_config",
default_profile_global()["radiometric_config"],
)
legacy = cfg.get("global_roi_pct", default_roi())
by_role = cfg.setdefault("global_roi_pct_by_role", make_roi_by_role(legacy))
def set_global_roi(data: dict, roi_pct: dict):
data.setdefault("global_scene_mode", default_profile_global())
data["global_scene_mode"].setdefault("radiometric_config", default_profile_global()["radiometric_config"])
data["global_scene_mode"]["radiometric_config"]["global_roi_pct"] = roi_pct
for role in ROLES:
if role not in by_role or not by_role[role]:
by_role[role] = clone_roi(legacy)
return by_role
def get_global_roi_for_role(data: dict, role: str):
role = normalize_role(role)
by_role = ensure_global_roi_by_role(data)
return by_role.get(role, clone_roi(default_roi()))
def set_global_roi_for_role(data: dict, role: str, roi_pct: dict):
role = normalize_role(role)
by_role = ensure_global_roi_by_role(data)
by_role[role] = roi_pct
# Compatibilidade: mantém uma ROI antiga preenchida.
# Uso: média/legado/visual antigo. O controller novo deverá usar by_role.
data["global_scene_mode"]["radiometric_config"]["global_roi_pct"] = by_role.get("rgb", roi_pct)
def get_patches(data: dict):
@ -359,32 +514,77 @@ def get_patches(data: dict):
)
def set_patch_roi(data: dict, patch_type: str, roi_pct: dict):
def ensure_patch_roi_by_role(patch: dict):
legacy = patch.get("roi_pct", {})
by_role = patch.setdefault("roi_pct_by_role", {})
for role in ROLES:
if role not in by_role or not by_role[role]:
by_role[role] = clone_roi(legacy) if legacy else {}
return by_role
def get_patch_by_type(data: dict, patch_type: str):
patch_type = str(patch_type).lower()
for p in get_patches(data):
if str(p.get("type", "")).lower() == patch_type:
return p
return None
def get_patch_roi_for_role(data: dict, patch_type: str, role: str):
role = normalize_role(role)
patch = get_patch_by_type(data, patch_type)
if not patch:
return {}
by_role = ensure_patch_roi_by_role(patch)
return by_role.get(role, {}) or patch.get("roi_pct", {}) or {}
def set_patch_roi_for_role(data: dict, patch_type: str, role: str, roi_pct: dict):
data.setdefault("three_reference_patches_mode", default_profile_patches())
cfg = data["three_reference_patches_mode"].setdefault(
"radiometric_config",
default_profile_patches()["radiometric_config"],
)
patches = cfg.setdefault("reference_patches", default_profile_patches()["radiometric_config"]["reference_patches"])
patches = cfg.setdefault(
"reference_patches",
default_profile_patches()["radiometric_config"]["reference_patches"],
)
patch_type = str(patch_type).lower()
role = normalize_role(role)
target = {"black": 0.06, "gray": 0.40, "white": 0.78}.get(patch_type, 0.40)
weight = {"black": 0.25, "gray": 1.0, "white": 0.7}.get(patch_type, 1.0)
patch = None
for p in patches:
if str(p.get("type", "")).lower() == patch_type:
p["roi_pct"] = roi_pct
return
patch = p
break
# Fallback se não existir.
target = {"black": 0.08, "gray": 0.40, "white": 0.82}.get(patch_type, 0.40)
weight = {"black": 0.7, "gray": 1.0, "white": 0.8}.get(patch_type, 1.0)
if patch is None:
patch = {
"name": f"{patch_type}_reference",
"type": patch_type,
"roles": ROLES[:],
"target_value": target,
"weight": weight,
"roi_pct": {},
"roi_pct_by_role": {},
}
patches.append(patch)
patches.append({
"name": f"{patch_type}_reference",
"type": patch_type,
"roles": ["rgb", "re", "nir"],
"roi_pct": roi_pct,
"target_value": target,
"weight": weight,
})
by_role = ensure_patch_roi_by_role(patch)
by_role[role] = roi_pct
# Compatibilidade com formato antigo.
# Mantém roi_pct como RGB, para scripts antigos não quebrarem.
patch["roi_pct"] = by_role.get("rgb", roi_pct)
def set_shared_mode(data: dict, shared: bool):
@ -394,6 +594,8 @@ def set_shared_mode(data: dict, shared: bool):
cfg["spectral_control_mode"] = "shared" if shared else "independent"
cfg["apply_same_spectral_to_both"] = bool(shared)
update_root_radiometric_config(data)
# ============================================================
# UI
@ -422,47 +624,113 @@ def draw_roi_on_panel(panel, roi_pct, label, color, thickness=2):
0.55, color, 1, cv2.LINE_AA)
def draw_all_rois(panel, data, selected_target, mode):
def draw_all_rois(panel, data, selected_target, mode, panel_role, edit_role):
panel_role = normalize_role(panel_role)
edit_role = normalize_role(edit_role)
is_edit_panel = panel_role == edit_role
if mode == "global":
roi = get_global_roi(data)
draw_roi_on_panel(panel, roi, "GLOBAL", PATCH_COLORS["global"], 2)
roi = get_global_roi_for_role(data, panel_role)
label = f"GLOBAL/{panel_role.upper()}"
thickness = 3 if is_edit_panel else 2
draw_roi_on_panel(panel, roi, label, PATCH_COLORS["global"], thickness)
else:
for p in get_patches(data):
typ = str(p.get("type", "")).lower()
color = PATCH_COLORS.get(typ, (0, 255, 255))
label = typ.upper()
thickness = 3 if typ == selected_target else 2
draw_roi_on_panel(panel, p.get("roi_pct"), label, color, thickness)
roi = get_patch_roi_for_role(data, typ, panel_role)
label = f"{typ.upper()}/{panel_role.upper()}"
selected = typ == selected_target and is_edit_panel
thickness = 3 if selected else 2
draw_roi_on_panel(panel, roi, label, color, thickness)
def build_board(decoded, data, mode, selected_target, drag_rect_local, panel_rects, preview_scale=1.0):
def build_board(
decoded,
data,
mode,
selected_target,
edit_role,
drag_rect_local,
drag_role,
panel_rects,
preview_scale=1.0,
visual_previews=None,
meta=None,
beauty_preview=True,
):
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")
# ------------------------------------------------------------
# Tamanho base SEMPRE vem do decoded, porque ROI/stats usam dado real.
# O preview visual é só para desenhar bonito.
# ------------------------------------------------------------
if rgb01 is not None:
rgb_panel = to_bgr_u8_from_rgb01(rgb01)
base_h, base_w = rgb01.shape[:2]
elif re01 is not None:
base_h, base_w = re01.shape[:2]
elif nir01 is not None:
base_h, base_w = nir01.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
# ------------------------------------------------------------
# Preview bonito, igual ao capture.
# ------------------------------------------------------------
rgb_vis_id, rgb_vis = get_visual_preview_by_role(visual_previews, meta, "rgb")
re_vis_id, re_vis = get_visual_preview_by_role(visual_previews, meta, "re")
nir_vis_id, nir_vis = get_visual_preview_by_role(visual_previews, meta, "nir")
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)
if beauty_preview and rgb_vis is not None:
rgb_panel = rgb_vis.copy()
if rgb_panel.shape[:2] != (base_h, base_w):
rgb_panel = cv2.resize(rgb_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
rgb_id = rgb_vis_id
else:
if rgb01 is not None:
rgb_panel = to_bgr_u8_from_rgb01(rgb01)
else:
rgb_panel = np.zeros((base_h, base_w, 3), dtype=np.uint8)
overlay_hud(rgb_panel, ["RGB", "sem frame"])
for p in (rgb_panel, re_panel, nir_panel):
draw_all_rois(p, data, selected_target, mode)
if beauty_preview and re_vis is not None:
re_panel = re_vis.copy()
if re_panel.shape[:2] != (base_h, base_w):
re_panel = cv2.resize(re_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
re_id = re_vis_id
else:
re01_show = resize_if_needed(re01, (base_h, base_w)) if re01 is not None else None
re_panel = gray_to_bgr_u8(re01_show) if re01_show is not None else np.zeros_like(rgb_panel)
if beauty_preview and nir_vis is not None:
nir_panel = nir_vis.copy()
if nir_panel.shape[:2] != (base_h, base_w):
nir_panel = cv2.resize(nir_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
nir_id = nir_vis_id
else:
nir01_show = resize_if_needed(nir01, (base_h, base_w)) if nir01 is not None else None
nir_panel = gray_to_bgr_u8(nir01_show) if nir01_show is not None else np.zeros_like(rgb_panel)
draw_all_rois(rgb_panel, data, selected_target, mode, "rgb", edit_role)
draw_all_rois(re_panel, data, selected_target, mode, "re", edit_role)
draw_all_rois(nir_panel, data, selected_target, mode, "nir", edit_role)
if drag_rect_local is not None:
x0, y0, x1, y1 = drag_rect_local
color = PATCH_COLORS["global"] if mode == "global" else PATCH_COLORS.get(selected_target, (0, 255, 255))
for p in (rgb_panel, re_panel, nir_panel):
cv2.rectangle(p, (x0, y0), (x1, y1), color, 1)
if drag_role == "rgb":
cv2.rectangle(rgb_panel, (x0, y0), (x1, y1), color, 1)
elif drag_role == "re":
cv2.rectangle(re_panel, (x0, y0), (x1, y1), color, 1)
elif drag_role == "nir":
cv2.rectangle(nir_panel, (x0, y0), (x1, y1), color, 1)
overlay_hud(rgb_panel, [f"RGB ({rgb_id})"], y=24)
overlay_hud(re_panel, [f"RE ({re_id})"], y=24)
@ -492,7 +760,7 @@ def build_board(decoded, data, mode, selected_target, drag_rect_local, panel_rec
board = np.vstack([top, bottom])
x0, y0, x1, y1 = panel_rects["data"]
lines = build_data_lines(decoded, data, mode, selected_target, base_w, base_h)
lines = build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, base_h)
overlay_hud(board, lines, x=x0 + 16, y=y0 + 28, font_scale=0.53, line_step=21)
if preview_scale != 1.0:
@ -505,51 +773,58 @@ def build_board(decoded, data, mode, selected_target, drag_rect_local, panel_rec
return board
def build_data_lines(decoded, data, mode, selected_target, base_w, base_h):
shared = (
data.get("global_scene_mode", {})
.get("radiometric_config", {})
.get("spectral_control_mode", "shared")
)
def build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, base_h):
edit_role = normalize_role(edit_role)
active_profile = get_active_profile_name(data)
active_cfg = get_active_radiometric_config(data)
lines = [
"RADIOMETRIC CONFIG TOOL",
f"modo_edição={mode.upper()} | spectral={shared}",
f"modo_edicao={mode.upper()} | camera_editada={edit_role.upper()} | active={active_profile}",
f"spectral={active_cfg.get('spectral_control_mode')} | strategy={active_cfg.get('control_strategy')}",
f"interval={active_cfg.get('interval_s')}s | ratio_alpha={active_cfg.get('ratio_alpha')} | ready={active_cfg.get('ready_required_cycles')}",
"",
"Arraste com o mouse no painel RGB para definir ROI.",
"A ROI é salva em percentuais e aplicada aos 3 sensores.",
"Arraste no painel da camera editada para definir a ROI.",
"Cada camera salva sua propria ROI: RGB / RE / NIR.",
"",
]
if mode == "global":
roi_pct = get_global_roi(data)
lines.append(f"GLOBAL ROI: {roi_pct}")
lines.extend(stats_lines_for_roi(decoded, roi_pct, base_w, base_h))
lines.append("GLOBAL ROI por camera:")
for role in ROLES:
roi_pct = get_global_roi_for_role(data, role)
marker = "*" if role == edit_role else " "
lines.append(f"{marker} {role.upper()}: roi={roi_pct}")
lines.append("")
lines.append("Stats GLOBAL:")
lines.extend(stats_lines_for_mode(data, decoded, mode="global", patch_type=None))
else:
lines.append(f"PATCH selecionado: {selected_target.upper()}")
for p in get_patches(data):
typ = str(p.get("type", "")).lower()
roi_pct = p.get("roi_pct", {})
lines.append(
f"{typ}: target={float(p.get('target_value', 0.0)):.2f} "
f"weight={float(p.get('weight', 1.0)):.2f}"
)
lines.append(f" roi={roi_pct}")
sel_patch = None
for p in get_patches(data):
if str(p.get("type", "")).lower() == selected_target:
sel_patch = p
break
lines.append("ROIs do patch selecionado:")
for role in ROLES:
roi_pct = get_patch_roi_for_role(data, selected_target, role)
marker = "*" if role == edit_role else " "
lines.append(f"{marker} {role.upper()}: roi={roi_pct}")
sel_patch = get_patch_by_type(data, selected_target)
if sel_patch:
lines.append("")
lines.append(f"Stats do patch {selected_target.upper()}:")
lines.extend(stats_lines_for_roi(decoded, sel_patch.get("roi_pct", {}), base_w, base_h))
lines.append(
f"target={float(sel_patch.get('target_value', 0.0)):.2f} "
f"weight={float(sel_patch.get('weight', 1.0)):.2f}"
)
lines.append("")
lines.append(f"Stats do patch {selected_target.upper()}:")
lines.extend(stats_lines_for_mode(data, decoded, mode="patches", patch_type=selected_target))
lines.extend([
"",
"M = alterna GLOBAL / 3 PATCHES",
"C = alterna camera RGB / RE / NIR",
"V = alterna preview bonito / bruto",
"1/2/3 = BLACK / GRAY / WHITE",
"S = alterna spectral shared/independent",
"P ou SPACE = salva JSON",
@ -559,17 +834,24 @@ def build_data_lines(decoded, data, mode, selected_target, base_w, base_h):
return lines
def stats_lines_for_roi(decoded, roi_pct, base_w, base_h):
if not roi_pct:
return ["sem ROI"]
def stats_lines_for_mode(data, decoded, mode: str, patch_type: str | None = None):
lines = []
for role in ("rgb", "re", "nir"):
for role in ROLES:
_, img = get_image_by_role(decoded, role)
if img is None:
lines.append(f"{role.upper()}: sem frame")
continue
if mode == "global":
roi_pct = get_global_roi_for_role(data, role)
else:
roi_pct = get_patch_roi_for_role(data, patch_type, role)
if not roi_pct:
lines.append(f"{role.upper()}: sem ROI")
continue
h, w = img.shape[:2]
roi = pct_to_px(roi_pct, w, h)
st = compute_stats(img, roi)
@ -622,6 +904,11 @@ def main():
mode = "global"
selected_target = "gray"
edit_role = "rgb"
drag_role = None
beauty_preview = True
visual_previews_last = {}
raw_meta_last = {}
panel_rects = {"rgb": None, "re": None, "nir": None, "data": None}
dragging = False
@ -636,21 +923,22 @@ def main():
window_name = "Radiometric Config Tool"
def on_mouse(event, x, y, flags, param):
nonlocal dragging, drag_start, drag_rect_local, last_msg, last_msg_t, data
nonlocal dragging, drag_start, drag_rect_local, last_msg, last_msg_t, data, drag_role
# Coordenadas vêm depois do preview_scale. Reescala para board real.
if args.preview_scale != 1.0:
x = int(x / args.preview_scale)
y = int(y / args.preview_scale)
rgb_rect = panel_rects.get("rgb")
if not rect_inside(rgb_rect, x, y):
edit_rect = panel_rects.get(edit_role)
if not rect_inside(edit_rect, x, y):
return
lx, ly = local_from_rect(rgb_rect, x, y)
lx, ly = local_from_rect(edit_rect, x, y)
if event == cv2.EVENT_LBUTTONDOWN:
dragging = True
drag_role = edit_role
drag_start = (lx, ly)
drag_rect_local = (lx, ly, lx + 1, ly + 1)
@ -664,24 +952,28 @@ def main():
rect = (sx, sy, lx, ly)
drag_rect_local = None
# Descobre tamanho local do painel RGB.
if rgb_rect is None:
# Descobre tamanho local do painel da camera editada.
edit_rect = panel_rects.get(drag_role or edit_role)
if edit_rect is None:
return
_, _, x1, y1 = rgb_rect
x0r, y0r, _, _ = rgb_rect
_, _, x1, y1 = edit_rect
x0r, y0r, _, _ = edit_rect
w = x1 - x0r
h = y1 - y0r
roi_pct = px_to_pct(rect, w, h)
if mode == "global":
set_global_roi(data, roi_pct)
last_msg = f"GLOBAL ROI atualizada: {roi_pct}"
else:
set_patch_roi(data, selected_target, roi_pct)
last_msg = f"{selected_target.upper()} ROI atualizada: {roi_pct}"
role_to_save = normalize_role(drag_role or edit_role)
if mode == "global":
set_global_roi_for_role(data, role_to_save, roi_pct)
last_msg = f"GLOBAL ROI {role_to_save.upper()} atualizada: {roi_pct}"
else:
set_patch_roi_for_role(data, selected_target, role_to_save, roi_pct)
last_msg = f"{selected_target.upper()} ROI {role_to_save.upper()} atualizada: {roi_pct}"
drag_role = None
last_msg_t = time.time()
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
@ -704,10 +996,20 @@ def main():
while True:
raw_frame, raw_meta, decoded = cam.get_next_decoded(timeout=2.0)
visual_previews = {}
try:
if isinstance(raw_frame, dict):
visual_previews = cam.build_visual_preview_from_raw(raw_frame, raw_meta)
except Exception as e:
visual_previews = {}
print(f"[WARN] Falha ao gerar beauty preview: {e}")
if raw_meta is not None and raw_meta.get("frame_id") != last_frame_id:
last_frame_id = raw_meta.get("frame_id")
decoded_last = decoded
visual_previews_last = visual_previews
raw_meta_last = raw_meta
if decoded_last:
board = build_board(
@ -715,9 +1017,14 @@ def main():
data=data,
mode=mode,
selected_target=selected_target,
edit_role=edit_role,
drag_rect_local=drag_rect_local,
drag_role=drag_role,
panel_rects=panel_rects,
preview_scale=args.preview_scale,
visual_previews=visual_previews_last,
meta=raw_meta_last,
beauty_preview=beauty_preview,
)
if last_msg and (time.time() - last_msg_t) < 2.5:
@ -737,24 +1044,38 @@ def main():
elif k in (ord("m"), ord("M")):
mode = "patches" if mode == "global" else "global"
last_msg = f"Modo -> {mode}"
if mode == "global":
set_active_profile_name(data, "global_scene_mode")
else:
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = f"Modo -> {mode} | active_profile={data['active_profile']}"
last_msg_t = time.time()
elif k == ord("1"):
mode = "patches"
selected_target = "black"
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: BLACK"
last_msg_t = time.time()
elif k == ord("2"):
mode = "patches"
selected_target = "gray"
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: GRAY"
last_msg_t = time.time()
elif k == ord("3"):
mode = "patches"
selected_target = "white"
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: WHITE"
last_msg_t = time.time()
@ -772,11 +1093,32 @@ def main():
elif k in (ord("r"), ord("R")):
data = {
"schema": "multispec_radiometric_config_profiles_v1",
"schema": "multispec_radiometric_config_profiles_v3",
"saved_at": now_str(),
"active_profile": "global_scene_mode",
"global_scene_mode": default_profile_global(),
"three_reference_patches_mode": default_profile_patches(),
"patch_normalization": {
"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
},
"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
}
}
update_root_radiometric_config(data)
last_msg = "Defaults restaurados"
last_msg_t = time.time()
@ -786,9 +1128,20 @@ def main():
last_msg_t = time.time()
print(f"[OK] radiometric config salvo em: {args.out_json}")
elif k in (ord("c"), ord("C")):
idx = ROLES.index(edit_role) if edit_role in ROLES else 0
edit_role = ROLES[(idx + 1) % len(ROLES)]
last_msg = f"Camera editada -> {edit_role.upper()}"
last_msg_t = time.time()
elif k in (ord("v"), ord("V")):
beauty_preview = not beauty_preview
last_msg = f"Beauty Preview -> {beauty_preview}"
last_msg_t = time.time()
finally:
cv2.destroyAllWindows()
print("Fim da parametrização radiométrica.")
print("Fim da parametrizacao radiometrica.")
if __name__ == "__main__":