ajustes na calibragem pelos cartões wb
|
|
@ -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(
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 602 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 1.5 MiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}%"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 já pronto
|
||||
- MULTISPEC uint8/float32 já pronto
|
||||
- RAW_BRUTO multi_payload (cam2 RGB + cam0/cam1 packed)
|
||||
"""
|
||||
def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None):
|
||||
frame_type = meta.get("frame_type")
|
||||
dtype_str = meta.get("dtype") or meta.get("output_dtype", "uint8")
|
||||
camera_frames = meta.get("camera_frames", {}) or {}
|
||||
|
||||
# -------------------------------------------------
|
||||
# RAW_BRUTO multi_payload
|
||||
# -------------------------------------------------
|
||||
if frame_type == "RAW_BRUTO":
|
||||
if not isinstance(frame, dict):
|
||||
raise RuntimeError("RAW_BRUTO esperado como dict de câmeras no modo multi")
|
||||
decoded = self.decode_stream_cameras(frame, meta)
|
||||
tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
|
||||
tensor = self.resize_tensor_chw(tensor, target_size=target_size)
|
||||
tensor = self.apply_patch_normalization_to_tensor(tensor)
|
||||
return tensor
|
||||
|
||||
arrays = []
|
||||
channel_names = []
|
||||
|
||||
# RGB USB
|
||||
if "cam2" in frame:
|
||||
rgb_bgr = frame["cam2"]
|
||||
if rgb_bgr.ndim != 3 or rgb_bgr.shape[2] != 3:
|
||||
raise RuntimeError(f"cam2 RGB inválida: shape={rgb_bgr.shape}")
|
||||
|
||||
rgb = rgb_bgr[:, :, ::-1].astype(np.float32) / 255.0
|
||||
rgb_chw = np.transpose(rgb, (2, 0, 1))
|
||||
arrays.append(rgb_chw)
|
||||
channel_names.extend(["R", "G", "B"])
|
||||
else:
|
||||
raise RuntimeError("RAW_BRUTO para inferência precisa incluir cam2 (RGB)")
|
||||
|
||||
# RE / NIR
|
||||
for cam_id, spec_name in (("cam0", "RE"), ("cam1", "NIR")):
|
||||
if cam_id not in frame:
|
||||
continue
|
||||
|
||||
packed = frame[cam_id]
|
||||
if packed.ndim == 3 and packed.shape[2] == 1:
|
||||
packed = packed[:, :, 0]
|
||||
|
||||
cam_meta = camera_frames.get(cam_id, {})
|
||||
packed_width = int(cam_meta.get("width", packed.shape[1]))
|
||||
height = int(cam_meta.get("height", packed.shape[0]))
|
||||
bayer = cam_meta.get("bayer_pattern", self.bayer_pattern)
|
||||
bit_depth = int(cam_meta.get("bit_depth", 10))
|
||||
|
||||
if bit_depth == 10:
|
||||
real_width = int((packed_width * 8) / 10)
|
||||
else:
|
||||
real_width = packed_width
|
||||
|
||||
rp = RawProcessorCore(
|
||||
sensor_width=real_width,
|
||||
sensor_height=height,
|
||||
bayer_pattern=bayer,
|
||||
)
|
||||
|
||||
raw16 = rp.unpack_raw10_packed(packed)
|
||||
|
||||
max_val = float((1 << bit_depth) - 1)
|
||||
single = np.clip(raw16.astype(np.float32) / max_val, 0.0, 1.0)[None, :, :]
|
||||
|
||||
arrays.append(single)
|
||||
channel_names.append(spec_name)
|
||||
|
||||
if len(arrays) < 2:
|
||||
raise RuntimeError("RAW_BRUTO requer RGB + pelo menos um canal espectral para inferência")
|
||||
|
||||
min_h = min(a.shape[1] for a in arrays)
|
||||
min_w = min(a.shape[2] for a in arrays)
|
||||
arrays = [a[:, :min_h, :min_w] for a in arrays]
|
||||
|
||||
raw_np = np.concatenate(arrays, axis=0)
|
||||
|
||||
if raw_np.shape[0] != channels_expected:
|
||||
raise RuntimeError(
|
||||
f"Tensor RAW_BRUTO montado com canais inesperados: {raw_np.shape[0]} | esperado={channels_expected} | got={channel_names}"
|
||||
)
|
||||
|
||||
return raw_np
|
||||
|
||||
# -------------------------------------------------
|
||||
# RGB ou MULTISPEC já pronto
|
||||
# -------------------------------------------------
|
||||
if frame_type == "RGB" or frame_type == "MULTISPEC":
|
||||
if frame_type in ("RGB", "MULTISPEC"):
|
||||
if not isinstance(frame, np.ndarray):
|
||||
raise RuntimeError(f"Frame {frame_type} esperado como ndarray")
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||