Binary search to approach the boundary.

def binary_search_batch(original_image, perturbed_images, decision_function,
                        shape, constraint, theta):
  """ Binary search to approach the boundary. """

  # Compute distance between each of perturbed image and original image.
  dists_post_update = np.array([
      compute_distance(
          original_image,
          perturbed_image,
          constraint
      )
      for perturbed_image in perturbed_images])

  # Choose upper thresholds in binary searchs based on constraint.
  if constraint == 'linf':
    highs = dists_post_update
    # Stopping criteria.
    thresholds = np.minimum(dists_post_update * theta, theta)
  else:
    highs = np.ones(len(perturbed_images))
    thresholds = theta

  lows = np.zeros(len(perturbed_images))

  while np.max((highs - lows) / thresholds) > 1:
    # projection to mids.
    mids = (highs + lows) / 2.0
    mid_images = project(original_image, perturbed_images,
                         mids, shape, constraint)

    # Update highs and lows based on model decisions.
    decisions = decision_function(mid_images)
    lows = np.where(decisions == 0, mids, lows)
    highs = np.where(decisions == 1, mids, highs)

  out_images = project(original_image, perturbed_images,
                       highs, shape, constraint)

  # Compute distance of the output image to select the best choice.
  # (only used when stepsize_search is grid_search.)
  dists = np.array([
      compute_distance(
          original_image,
          out_image,
          constraint
      )
      for out_image in out_images])
  idx = np.argmin(dists)

  dist = dists_post_update[idx]
  out_image = out_images[idx]
  return out_image, dist