xref: /aosp_15_r20/cts/apps/CameraITS/tools/dng_noise_model.py (revision b7c941bb3fa97aba169d73cee0bed2de8ac964bf)
1# Copyright 2014 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""CameraITS script to generate noise models."""
15
16import logging
17import math
18import os.path
19import pathlib
20import pickle
21import tempfile
22import textwrap
23
24import capture_read_noise_utils
25import its_base_test
26import its_session_utils
27from matplotlib import pyplot as plt
28import matplotlib.ticker
29from mobly import test_runner
30import noise_model_constants
31import noise_model_utils
32import numpy as np
33
34_IS_QUAD_BAYER = False  # A manual flag to choose standard or quad Bayer noise
35                        # model generation.
36if _IS_QUAD_BAYER:
37  _COLOR_CHANNEL_NAMES = noise_model_constants.QUAD_BAYER_COLORS
38  _PLOT_COLORS = noise_model_constants.QUAD_BAYER_PLOT_COLORS
39  _TILE_SIZE = 64  # Tile size to compute mean/variance. Large tiles may have
40                   # their variance corrupted by low freq image changes.
41  _STATS_FORMAT = 'raw10QuadBayerStats'  # rawQuadBayerStats|raw10QuadBayerStats
42  _READ_NOISE_RAW_FORMAT = 'raw10QuadBayer'  # rawQuadBayer|raw10QuadBayer
43else:
44  _COLOR_CHANNEL_NAMES = noise_model_constants.BAYER_COLORS
45  _PLOT_COLORS = noise_model_constants.BAYER_PLOT_COLORS
46  _TILE_SIZE = 32  # Tile size to compute mean/variance. Large tiles may have
47                   # their variance corrupted by low freq image changes.
48  _STATS_FORMAT = 'rawStats'  # rawStats|raw10Stats
49  _READ_NOISE_RAW_FORMAT = 'raw'  # raw|raw10
50
51_STATS_CONFIG = {
52    'format': _STATS_FORMAT,
53    'gridWidth': _TILE_SIZE,
54    'gridHeight': _TILE_SIZE,
55}
56_BRACKET_MAX = 8  # Exposure bracketing range in stops
57_BRACKET_FACTOR = math.pow(2, _BRACKET_MAX)
58_ISO_MAX_VALUE = None  # ISO range max value, uses sensor max if None
59_ISO_MIN_VALUE = None  # ISO range min value, uses sensor min if None
60_MAX_SCALE_FUDGE = 1.1
61_MAX_SIGNAL_VALUE = 0.25  # Maximum value to allow mean of the tiles to go.
62_NAME = os.path.basename(__file__).split('.')[0]
63_NAME_READ_NOISE = os.path.join(tempfile.gettempdir(), 'CameraITS/ReadNoise')
64_NAME_READ_NOISE_FILE = 'read_noise_results.pkl'
65_STATS_FILE_NAME = 'stats.pkl'
66_OUTLIER_MEDIAN_ABS_DEVS = 10  # Defines the number of Median Absolute
67                               # Deviations that constitutes acceptable data
68_READ_NOISE_STEPS_PER_STOP = 12  # Sensitivities per stop to sample for read
69                                 # noise
70_REMOVE_VAR_OUTLIERS = False  # When True, filters the variance to remove
71                              # outliers
72_STEPS_PER_STOP = 3  # How many sensitivities per stop to sample.
73_ISO_MULTIPLIER = math.pow(2, 1.0 / _STEPS_PER_STOP)
74_TILE_CROP_N = 0  # Number of tiles to crop from edge of image. Usually 0.
75_TWO_STAGE_MODEL = False  # Require read noise data prior to running noise model
76_ZOOM_RATIO = 1  # Zoom target to be used while running the model
77_FIG_DPI = 100  # DPI for plotting noise model figures.
78_BAYER_COLORS_FOR_NOISE_PROFILE = tuple(
79    map(str.lower, noise_model_constants.BAYER_COLORS)
80)
81_QUAD_BAYER_COLORS_FOR_NOISE_PROFILE = tuple(
82    map(str.lower, noise_model_constants.QUAD_BAYER_COLORS)
83)
84
85
86class DngNoiseModel(its_base_test.ItsBaseTest):
87  """Create DNG noise model.
88
89  Captures RAW images with increasing analog gains to create the model.
90  """
91
92  def _create_noise_model_code(self, noise_model, sens_min, sens_max,
93                               sens_max_analog, file_path):
94    """Creates the C file for the noise model.
95
96    Args:
97      noise_model: Noise model parameters.
98      sens_min: The minimum sensitivity value.
99      sens_max: The maximum sensitivity value.
100      sens_max_analog: The maximum analog sensitivity value.
101      file_path: The path to the noise model file.
102    """
103    # Generate individual noise model components.
104    scale_a, scale_b, offset_a, offset_b = zip(*noise_model)
105    digital_gain_cdef = (
106        f'(sens / {sens_max_analog:.1f}) < 1.0 ? '
107        f'1.0 : (sens / {sens_max_analog:.1f})'
108    )
109
110    with open(file_path, 'w') as text_file:
111      scale_a_str = ','.join([str(i) for i in scale_a])
112      scale_b_str = ','.join([str(i) for i in scale_b])
113      offset_a_str = ','.join([str(i) for i in offset_a])
114      offset_b_str = ','.join([str(i) for i in offset_b])
115      # pylint: disable=line-too-long
116      code = textwrap.dedent(f"""\
117              /* Generated test code to dump a table of data for external validation
118              * of the noise model parameters.
119              */
120              #include <stdio.h>
121              #include <assert.h>
122              double compute_noise_model_entry_S(int plane, int sens);
123              double compute_noise_model_entry_O(int plane, int sens);
124              int main(void) {{
125                  for (int plane = 0; plane < {len(scale_a)}; plane++) {{
126                      for (int sens = {sens_min}; sens <= {sens_max}; sens += 100) {{
127                          double o = compute_noise_model_entry_O(plane, sens);
128                          double s = compute_noise_model_entry_S(plane, sens);
129                          printf("%d,%d,%lf,%lf\\n", plane, sens, o, s);
130                      }}
131                  }}
132                  return 0;
133              }}
134
135              /* Generated functions to map a given sensitivity to the O and S noise
136              * model parameters in the DNG noise model. The planes are in
137              * R, Gr, Gb, B order.
138              */
139              double compute_noise_model_entry_S(int plane, int sens) {{
140                  static double noise_model_A[] = {{ {scale_a_str:s} }};
141                  static double noise_model_B[] = {{ {scale_b_str:s} }};
142                  double A = noise_model_A[plane];
143                  double B = noise_model_B[plane];
144                  double s = A * sens + B;
145                  return s < 0.0 ? 0.0 : s;
146              }}
147
148              double compute_noise_model_entry_O(int plane, int sens) {{
149                  static double noise_model_C[] = {{ {offset_a_str:s} }};
150                  static double noise_model_D[] = {{ {offset_b_str:s} }};
151                  double digital_gain = {digital_gain_cdef:s};
152                  double C = noise_model_C[plane];
153                  double D = noise_model_D[plane];
154                  double o = C * sens * sens + D * digital_gain * digital_gain;
155                  return o < 0.0 ? 0.0 : o;
156              }}
157              """)
158
159      text_file.write(code)
160
161  def _create_noise_profile_code(self, noise_model, color_channels, file_path):
162    """Creates the noise profile C++ file.
163
164    Args:
165      noise_model: Noise model parameters.
166      color_channels: Color channels in canonical order.
167      file_path: The path to the noise profile C++ file.
168    """
169    # Generate individual noise model components.
170    scale_a, scale_b, offset_a, offset_b = zip(*noise_model)
171    num_channels = noise_model.shape[0]
172    params = []
173    for ch, color in enumerate(color_channels):
174      prefix = f'.noise_coefficients_{color} = {{'
175      spaces = ' ' * len(prefix)
176      suffix = '},' if ch != num_channels - 1 else '}'
177      params.append(textwrap.dedent(f"""
178        {prefix}.gradient_slope = {scale_a[ch]},
179        {spaces}.offset_slope = {scale_b[ch]},
180        {spaces}.gradient_intercept = {offset_a[ch]},
181        {spaces}.offset_intercept = {offset_b[ch]}{suffix}"""))
182
183    with open(file_path, 'w') as text_file:
184      # pylint: disable=line-too-long
185      code_comment = textwrap.dedent("""\
186              /* noise_profile.cc
187                Note: gradient_slope --> gradient of API s_measured parameter
188                      offset_slope --> o_model of API s_measured parameter
189                      gradient_intercept--> gradient of API o_measured parameter
190                      offset_intercept --> o_model of API o_measured parameter
191                Note: SENSOR_NOISE_PROFILE in Android Developers doc uses
192                      N(x) = sqrt(Sx + O), where 'S' is 's_measured' & 'O' is 'o_measured'
193              */
194      """)
195      params_str = textwrap.indent(''.join(params), ' ' * 4)
196      code_params = '.profile = {' + params_str + '},'
197      code = code_comment + code_params
198      text_file.write(code)
199
200  def _create_noise_model_and_profile_code(self, noise_model, sens_min,
201                                           sens_max, sens_max_analog, log_path):
202    """Creates the code file with noise model parameters.
203
204    Args:
205      noise_model: Noise model parameters.
206      sens_min: The minimum sensitivity value.
207      sens_max: The maximum sensitivity value.
208      sens_max_analog: The maximum analog sensitivity value.
209      log_path: The path to the log file.
210    """
211    noise_model_utils.check_noise_model_shape(noise_model)
212    # Create noise model code with noise model parameters.
213    self._create_noise_model_code(
214        noise_model,
215        sens_min,
216        sens_max,
217        sens_max_analog,
218        os.path.join(log_path, 'noise_model.c'),
219    )
220
221    num_channels = noise_model.shape[0]
222    is_quad_bayer = (
223        num_channels == noise_model_constants.NUM_QUAD_BAYER_CHANNELS
224    )
225    if is_quad_bayer:
226      # Average noise model parameters of every four channels.
227      avg_noise_model = noise_model.reshape(-1, 4, noise_model.shape[1]).mean(
228          axis=1
229      )
230      # Create noise model code with average noise model parameters.
231      self._create_noise_model_code(
232          avg_noise_model,
233          sens_min,
234          sens_max,
235          sens_max_analog,
236          os.path.join(log_path, 'noise_model_avg.c'),
237      )
238      # Create noise profile code with average noise model parameters.
239      self._create_noise_profile_code(
240          avg_noise_model,
241          _BAYER_COLORS_FOR_NOISE_PROFILE,
242          os.path.join(log_path, 'noise_profile_avg.cc'),
243      )
244      # Create noise profile code with noise model parameters.
245      self._create_noise_profile_code(
246          noise_model,
247          _QUAD_BAYER_COLORS_FOR_NOISE_PROFILE,
248          os.path.join(log_path, 'noise_profile.cc'),
249      )
250
251    else:
252      # Create noise profile code with noise model parameters.
253      self._create_noise_profile_code(
254          noise_model,
255          _BAYER_COLORS_FOR_NOISE_PROFILE,
256          os.path.join(log_path, 'noise_profile.cc'),
257      )
258
259  def _plot_stats_and_noise_model_fittings(
260      self, iso_to_stats_dict, measured_models, noise_model, sens_max_analog,
261      folder_path_prefix):
262    """Plots the stats (means, vars_) and noise models fittings.
263
264    Args:
265      iso_to_stats_dict: A dictionary mapping ISO to a list of tuples of
266        exposure time in milliseconds, mean values, and variance values.
267      measured_models: A list of measured noise models for each ISO value.
268      noise_model: A numpy array of global noise model parameters for all ISO
269        values.
270      sens_max_analog: The maximum analog sensitivity value.
271      folder_path_prefix: The prefix of path to save figures.
272
273    Raises:
274      ValueError: If the noise model shape is invalid.
275    """
276    noise_model_utils.check_noise_model_shape(noise_model)
277    # Separate individual noise model components.
278    scale_a, scale_b, offset_a, offset_b = zip(*noise_model)
279
280    iso_pidx_to_measured_model_dict = {}
281    num_channels = noise_model.shape[0]
282    for pidx in range(num_channels):
283      for iso, s_measured, o_measured in measured_models[pidx]:
284        iso_pidx_to_measured_model_dict[(iso, pidx)] = (s_measured, o_measured)
285
286    isos = np.asarray(sorted(iso_to_stats_dict.keys()))
287    digital_gains = noise_model_utils.compute_digital_gains(
288        isos, sens_max_analog
289    )
290
291    x_range = [0, _MAX_SIGNAL_VALUE]
292    for iso, digital_gain in zip(isos, digital_gains):
293      logging.info('Plotting stats and noise model for ISO %d.', iso)
294      fig, subplots = noise_model_utils.create_stats_figure(
295          iso, _COLOR_CHANNEL_NAMES
296      )
297
298      xmax = 0
299      stats_per_plane = [[] for _ in range(num_channels)]
300      for exposure_ms, means, vars_ in iso_to_stats_dict[iso]:
301        exposure_norm = noise_model_constants.COLOR_NORM(np.log2(exposure_ms))
302        exposure_color = noise_model_constants.RAINBOW_CMAP(exposure_norm)
303        for pidx in range(num_channels):
304          means_p = means[pidx]
305          vars_p = vars_[pidx]
306
307          if means_p.size > 0 and vars_p.size > 0:
308            subplots[pidx].plot(
309                means_p,
310                vars_p,
311                color=exposure_color,
312                marker='.',
313                markeredgecolor=exposure_color,
314                markersize=1,
315                linestyle='None',
316                alpha=0.5,
317            )
318
319            stats_per_plane[pidx].extend(list(zip(means_p, vars_p)))
320            xmax = max(xmax, max(means_p))
321
322      iso_sq = iso ** 2
323      digital_gain_sq = digital_gain ** 2
324      for pidx in range(num_channels):
325        # Add the final noise model to subplots.
326        s_model = scale_a[pidx] * iso * digital_gain + scale_b[pidx]
327        o_model = (offset_a[pidx] * iso_sq + offset_b[pidx]) * digital_gain_sq
328
329        plot_color = _PLOT_COLORS[pidx]
330        subplots[pidx].plot(
331            x_range,
332            [o_model, s_model * _MAX_SIGNAL_VALUE + o_model],
333            color=plot_color,
334            linestyle='-',
335            label='Model',
336            alpha=0.5,
337        )
338
339        # Add the noise model measured by captures with current iso to subplots.
340        if (iso, pidx) not in iso_pidx_to_measured_model_dict:
341          continue
342
343        s_measured, o_measured = iso_pidx_to_measured_model_dict[(iso, pidx)]
344
345        subplots[pidx].plot(
346            x_range,
347            [o_measured, s_measured * _MAX_SIGNAL_VALUE + o_measured],
348            color=plot_color,
349            linestyle='--',
350            label='Linear fit',
351        )
352
353        ymax = (o_measured + s_measured * xmax) * _MAX_SCALE_FUDGE
354        subplots[pidx].set_xlim(xmin=0, xmax=xmax)
355        subplots[pidx].set_ylim(ymin=0, ymax=ymax)
356        subplots[pidx].legend()
357
358      fig.savefig(
359          f'{folder_path_prefix}_samples_iso{iso:04d}.png', dpi=_FIG_DPI
360      )
361
362  def _plot_noise_model_single_plane(
363      self, pidx, plot, sens, measured_params, modeled_params):
364    """Plots the noise model for one color plane specified by pidx.
365
366    Args:
367      pidx: The index of the color plane in Bayer pattern.
368      plot: The ax to plot on.
369      sens: The sensitivity of the sensor.
370      measured_params:  The measured parameters.
371      modeled_params: The modeled parameters.
372    """
373    color_channel = _COLOR_CHANNEL_NAMES[pidx]
374    measured_label = f'{color_channel}-Measured'
375    model_label = f'{color_channel}-Model'
376
377    plot_color = _PLOT_COLORS[pidx]
378    # Plot the measured parameters.
379    plot.loglog(
380        sens,
381        measured_params,
382        color=plot_color,
383        marker='+',
384        markeredgecolor=plot_color,
385        linestyle='None',
386        base=10,
387        label=measured_label,
388    )
389    # Plot the modeled parameters.
390    plot.loglog(
391        sens,
392        modeled_params,
393        color=plot_color,
394        marker='o',
395        markeredgecolor=plot_color,
396        linestyle='None',
397        base=10,
398        label=model_label,
399        alpha=0.3,
400    )
401
402  def _plot_noise_model(self, isos, measured_models, noise_model,
403                        sens_max_analog, name_with_log_path):
404    """Plot the noise model for a given set of ISO values.
405
406    The read noise model is defined by the following equation:
407      f(x) = s_model * x + o_model
408    where we have:
409    s_model = scale_a * analog_gain * digital_gain + scale_b is the
410    multiplicative factor,
411    o_model = (offset_a * analog_gain^2 + offset_b) * digital_gain^2
412    is the offset term.
413
414    Args:
415      isos: A list of ISO values.
416      measured_models: A list of measured models, each of which is a tuple of
417        (sens, s_measured, o_measured).
418      noise_model: Noise model parameters of each plane, each of which is a
419        tuple of (scale_a, scale_b, offset_a, offset_b).
420      sens_max_analog: The maximum analog gain.
421      name_with_log_path: The name of the file to save the logs to.
422    """
423    noise_model_utils.check_noise_model_shape(noise_model)
424
425    # Plot noise model parameters.
426    fig, axes = plt.subplots(4, 2, figsize=(22, 17))
427    s_plots, o_plots = axes[:, 0], axes[:, 1]
428    num_channels = noise_model.shape[0]
429    is_quad_bayer = (
430        num_channels == noise_model_constants.NUM_QUAD_BAYER_CHANNELS
431    )
432    for pidx, measured_model in enumerate(measured_models):
433      # Grab the sensitivities and line parameters of each sensitivity.
434      sens, s_measured, o_measured = zip(*measured_model)
435      sens = np.asarray(sens)
436      sens_sq = np.square(sens)
437      scale_a, scale_b, offset_a, offset_b = noise_model[pidx]
438      # Plot noise model components with the values predicted by the model.
439      digital_gains = noise_model_utils.compute_digital_gains(
440          sens, sens_max_analog
441      )
442
443      # s_model = scale_a * analog_gain * digital_gain + scale_b,
444      # o_model = (offset_a * analog_gain^2 + offset_b) * digital_gain^2.
445      s_model = scale_a * sens * digital_gains + scale_b
446      o_model = (offset_a * sens_sq + offset_b) * np.square(digital_gains)
447      if is_quad_bayer:
448        s_plot, o_plot = s_plots[pidx // 4], o_plots[pidx // 4]
449      else:
450        s_plot, o_plot = s_plots[pidx], o_plots[pidx]
451
452      self._plot_noise_model_single_plane(
453          pidx, s_plot, sens, s_measured, s_model)
454      self._plot_noise_model_single_plane(
455          pidx, o_plot, sens, o_measured, o_model)
456
457    # Set figure attributes after plotting noise model parameters.
458    for s_plot, o_plot in zip(s_plots, o_plots):
459      s_plot.set_xlabel('ISO')
460      s_plot.set_ylabel('S')
461
462      o_plot.set_xlabel('ISO')
463      o_plot.set_ylabel('O')
464
465      for sub_plot in (s_plot, o_plot):
466        sub_plot.set_xticks(isos)
467        # No minor ticks.
468        sub_plot.xaxis.set_minor_locator(matplotlib.ticker.NullLocator())
469        sub_plot.xaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter())
470        sub_plot.legend()
471
472    fig.suptitle('Noise model: N(x) = sqrt(Sx + O)', x=0.54, y=0.99)
473    plt.tight_layout()
474    fig.savefig(f'{name_with_log_path}.png', dpi=_FIG_DPI)
475
476  def test_dng_noise_model_generation(self):
477    """Calibrates standard Bayer or quad Bayer noise model.
478
479    def requires 'test' in name to actually run.
480    This function:
481    * Calibrates read noise (optional).
482    * Captures stats images of different ISO values and exposure times.
483    * Measures linear fittings for each ISO value.
484    * Computes and validates overall noise model parameters.
485    * Plots noise model parameters figures.
486    * Plots stats samples, linear fittings and model fittings.
487    * Saves the read noise plot and csv data (optional).
488    * Generates noise model and noise profile code.
489    """
490    read_noise_file_path = capture_read_noise_utils.calibrate_read_noise(
491        self.dut.serial,
492        self.camera_id,
493        self.hidden_physical_id,
494        _NAME_READ_NOISE,
495        _NAME_READ_NOISE_FILE,
496        _READ_NOISE_STEPS_PER_STOP,
497        raw_format=_READ_NOISE_RAW_FORMAT,
498        is_two_stage_model=_TWO_STAGE_MODEL,
499    )
500
501    # Begin DNG Noise Model Calibration
502    with its_session_utils.ItsSession(
503        device_id=self.dut.serial,
504        camera_id=self.camera_id,
505        hidden_physical_id=self.hidden_physical_id) as cam:
506      props = cam.get_camera_properties()
507      props = cam.override_with_hidden_physical_camera_props(props)
508      log_path = self.log_path
509      name_with_log_path = os.path.join(log_path, _NAME)
510      logging.info('Starting %s for camera %s', _NAME, cam.get_camera_name())
511
512      # Get basic properties we need.
513      sens_min, sens_max = props['android.sensor.info.sensitivityRange']
514      sens_max_analog = props['android.sensor.maxAnalogSensitivity']
515      # Maximum sensitivity for measuring noise model.
516      sens_max_meas = sens_max_analog
517
518      # Change the ISO min and/or max values if specified
519      if _ISO_MIN_VALUE is not None:
520        sens_min = _ISO_MIN_VALUE
521      if _ISO_MAX_VALUE is not None:
522        sens_max_meas = _ISO_MAX_VALUE
523
524      logging.info('Sensitivity range: [%d, %d]', sens_min, sens_max)
525      logging.info('Max analog sensitivity: %d', sens_max_analog)
526      logging.info(
527          'Sensitivity range for measurement: [%d, %d]',
528          sens_min, sens_max_meas,
529      )
530
531      offset_a, offset_b = None, None
532      read_noise_data = None
533      if _TWO_STAGE_MODEL:
534        # Check if read noise results exist for this device and camera
535        if not os.path.exists(read_noise_file_path):
536          raise AssertionError(
537              'Read noise results file does not exist for this device. Run'
538              ' capture_read_noise_file_path script to gather read noise data'
539              ' for current sensor'
540          )
541
542        with open(read_noise_file_path, 'rb') as f:
543          read_noise_data = pickle.load(f)
544
545        offset_a, offset_b = (
546            capture_read_noise_utils.get_read_noise_coefficients(
547                read_noise_data,
548                sens_min,
549                sens_max_meas,
550            )
551        )
552
553      iso_to_stats_dict = noise_model_utils.capture_stats_images(
554          cam,
555          props,
556          _STATS_CONFIG,
557          sens_min,
558          sens_max_meas,
559          _ZOOM_RATIO,
560          _TILE_CROP_N,
561          _MAX_SIGNAL_VALUE,
562          _ISO_MULTIPLIER,
563          _BRACKET_MAX,
564          _BRACKET_FACTOR,
565          self.log_path,
566          stats_file_name=_STATS_FILE_NAME,
567          is_remove_var_outliers=_REMOVE_VAR_OUTLIERS,
568          outlier_median_abs_deviations=_OUTLIER_MEDIAN_ABS_DEVS,
569          is_debug_mode=self.debug_mode,
570      )
571
572    measured_models, samples = noise_model_utils.measure_linear_noise_models(
573        iso_to_stats_dict,
574        _COLOR_CHANNEL_NAMES,
575    )
576
577    noise_model = noise_model_utils.compute_noise_model(
578        samples,
579        sens_max_analog,
580        offset_a,
581        offset_b,
582        _TWO_STAGE_MODEL,
583    )
584
585    noise_model_utils.validate_noise_model(
586        noise_model,
587        _COLOR_CHANNEL_NAMES,
588        sens_min,
589    )
590
591    self._plot_noise_model(
592        sorted(iso_to_stats_dict.keys()),
593        measured_models,
594        noise_model,
595        sens_max_analog,
596        name_with_log_path,
597    )
598
599    self._plot_stats_and_noise_model_fittings(
600        iso_to_stats_dict,
601        measured_models,
602        noise_model,
603        sens_max_analog,
604        name_with_log_path,
605    )
606
607    # If 2-Stage model is enabled, save the read noise graph and csv data
608    if _TWO_STAGE_MODEL:
609      # Save the linear plot of the read noise data
610      filename = f'{pathlib.Path(_NAME_READ_NOISE_FILE).stem}.png'
611      file_path = os.path.join(log_path, filename)
612      capture_read_noise_utils.plot_read_noise_data(
613          read_noise_data,
614          sens_min,
615          sens_max_meas,
616          file_path,
617          _COLOR_CHANNEL_NAMES,
618          _PLOT_COLORS,
619      )
620
621      # Save the data as a csv file
622      filename = f'{pathlib.Path(_NAME_READ_NOISE_FILE).stem}.csv'
623      file_path = os.path.join(log_path, filename)
624      capture_read_noise_utils.save_read_noise_data_as_csv(
625          read_noise_data,
626          sens_min,
627          sens_max_meas,
628          file_path,
629          _COLOR_CHANNEL_NAMES,
630      )
631
632    # Generate the noise model file.
633    self._create_noise_model_and_profile_code(
634        noise_model,
635        sens_min,
636        sens_max,
637        sens_max_analog,
638        log_path,
639    )
640
641
642if __name__ == '__main__':
643  test_runner.main()
644