huge changes

This commit is contained in:
2025-06-19 15:31:45 -04:00
parent 2bcf6a3325
commit bb253155fb
18 changed files with 4243 additions and 129 deletions

160
border.py Normal file
View File

@ -0,0 +1,160 @@
import numpy as np
from PIL import Image, ImageDraw
from scipy.signal import find_peaks, savgol_filter
from scipy.ndimage import sobel
def analyze_profile(
profile: np.ndarray,
prominence: float,
width: int,
direction: str
) -> int | None:
"""
Analyzes a 1D profile to find the most likely edge coordinate.
"""
if profile.size == 0:
return None
# 1. Smooth the profile to reduce noise while preserving peak shapes.
# The window length must be odd.
window_length = min(21, len(profile) // 2 * 2 + 1)
if window_length < 5: # savgol_filter requires window_length > polyorder
smoothed_profile = profile
else:
smoothed_profile = savgol_filter(profile, window_length=window_length, polyorder=2)
# 2. Find all significant peaks in the profile.
# Prominence is a measure of how much a peak stands out from the baseline.
peaks, properties = find_peaks(smoothed_profile, prominence=prominence, width=width)
if len(peaks) == 0:
return None
# 3. Select the best peak. We choose the one with the highest prominence.
most_prominent_peak_index = np.argmax(properties['prominences'])
best_peak = peaks[most_prominent_peak_index]
return best_peak
def find_film_edges_gradient(
image_path: str,
border_percent: int = 15,
prominence: float = 10.0,
min_width: int = 2
) -> tuple[int, int, int, int] | None:
"""
Detects film edges using a directional gradient method, which is robust
to complex borders and internal image features.
Args:
image_path (str): Path to the image file.
border_percent (int): The percentage of image dimensions to search for a border.
prominence (float): Required prominence of a gradient peak to be considered an edge.
This is the most critical tuning parameter. Higher values mean
the edge must be sharper and more distinct.
min_width (int): The minimum width (in pixels) of a peak to be considered.
Helps ignore single-pixel noise.
Returns:
A tuple (left, top, right, bottom) or None if detection fails.
"""
try:
with Image.open(image_path) as img:
image_gray = np.array(img.convert('L'), dtype=float)
height, width = image_gray.shape
except Exception as e:
print(f"Error opening or processing image: {e}")
return None
# 1. Calculate directional gradients for the entire image once.
grad_y = sobel(image_gray, axis=0) # For horizontal lines (top, bottom)
grad_x = sobel(image_gray, axis=1) # For vertical lines (left, right)
coords = {}
search_w = int(width * border_percent / 100)
search_h = int(height * border_percent / 100)
# 2. Find Left Edge (Dark -> Light transition, so positive grad_x)
left_band_grad = grad_x[:, :search_w]
# We only care about positive gradients (dark to light)
left_profile = np.sum(np.maximum(0, left_band_grad), axis=0)
left_coord = analyze_profile(left_profile, prominence, min_width, "left")
coords['left'] = left_coord if left_coord is not None else 0
# 3. Find Right Edge (Light -> Dark transition, so negative grad_x)
right_band_grad = grad_x[:, -search_w:]
# We want the strongest negative gradient, so we flip the sign.
right_profile = np.sum(np.maximum(0, -right_band_grad), axis=0)
# The profile is from right-to-left, so we analyze its reversed version.
right_coord = analyze_profile(right_profile[::-1], prominence, min_width, "right")
if right_coord is not None:
# Convert coordinate back to the original image space
coords['right'] = width - 1 - right_coord
else:
coords['right'] = width - 1
# 4. Find Top Edge (Dark -> Light transition, so positive grad_y)
top_band_grad = grad_y[:search_h, :]
top_profile = np.sum(np.maximum(0, top_band_grad), axis=1)
top_coord = analyze_profile(top_profile, prominence, min_width, "top")
coords['top'] = top_coord if top_coord is not None else 0
# 5. Find Bottom Edge (Light -> Dark transition, so negative grad_y)
bottom_band_grad = grad_y[-search_h:, :]
bottom_profile = np.sum(np.maximum(0, -bottom_band_grad), axis=1)
bottom_coord = analyze_profile(bottom_profile[::-1], prominence, min_width, "bottom")
if bottom_coord is not None:
coords['bottom'] = height - 1 - bottom_coord
else:
coords['bottom'] = height - 1
# 6. Sanity check and return
left, top, right, bottom = map(int, [coords['left'], coords['top'], coords['right'], coords['bottom']])
if not (left < right and top < bottom):
print("Warning: Detection failed, coordinates are illogical.")
return None
return (left, top, right, bottom)
# --- Example Usage ---
if __name__ == "__main__":
# Use the image you provided.
# NOTE: You must save your image as 'test_negative_v2.png' in the same
# directory as the script, or change the path here.
image_file = "test_negative_v2.png"
print(f"Attempting to detect edges on '{image_file}' with gradient method...")
# Run the detection. The 'prominence' parameter is the one to tune.
# Start with a moderate value and increase if it detects noise, decrease
# if it misses a subtle edge.
crop_box = find_film_edges_gradient(
image_file,
border_percent=15, # Increase search area slightly due to complex borders
prominence=5.0, # A higher value is needed for this high-contrast example
min_width=2
)
if crop_box:
print(f"\nDetected image box: {crop_box}")
with Image.open(image_file) as img:
# Add a white border for better visualization if the background is black
img_with_border = Image.new('RGB', (img.width + 2, img.height + 2), 'white')
img_with_border.paste(img, (1, 1))
draw = ImageDraw.Draw(img_with_border)
# Offset the crop box by 1 pixel due to the added border
offset_box = [c + 1 for c in crop_box]
draw.rectangle(offset_box, outline="red", width=2)
output_path = "test_negative_detected_final.png"
img_with_border.save(output_path)
print(f"Saved visualization to '{output_path}'")
try:
img_with_border.show(title="Detection Result")
except Exception:
pass
else:
print("\nCould not robustly detect the film edges.")

163
compare.py Normal file
View File

@ -0,0 +1,163 @@
import os
from PIL import Image, ImageDraw, ImageFont
import matplotlib.pyplot as plt
import numpy as np
def create_advanced_comparison_poster(
image_paths,
output_path="05.jpg",
patch_size=(300, 300),
zoom_level=2.0
):
"""
Generates a poster optimized for side-by-side patch comparison from a
series of high-resolution images.
The layout is organized in rows:
- Row 1: Scaled-down full images.
- Row 2: Patch 1 from all images.
- Row 3: Patch 2 from all images.
- ... and so on.
- Final Row: Histograms for all images.
Args:
image_paths (list): A list of file paths for the images to compare.
output_path (str, optional): Path to save the output poster.
patch_size (tuple, optional): The (width, height) of the area to crop
from the source image.
zoom_level (float, optional): The factor to enlarge the cropped patches.
"""
if not image_paths:
print("No image paths were provided.")
return
# --- Layout & Font Configuration ---
padding = 25
header_height = 40
row_title_width = 150
histogram_height = 200
patch_display_size = (int(patch_size[0] * zoom_level), int(patch_size[1] * zoom_level))
scaled_full_image_width = patch_display_size[0]
try:
title_font = ImageFont.truetype("arialbd.ttf", 20)
header_font = ImageFont.truetype("arial.ttf", 16)
except IOError:
title_font = ImageFont.load_default()
header_font = ImageFont.load_default()
# --- Determine Poster Dimensions from Master Image ---
with Image.open(image_paths[0]) as master_image:
master_width, master_height = master_image.size
# Define patch locations relative to image dimensions
patch_definitions = {
"Top Left": (0, 0, patch_size[0], patch_size[1]),
"Top Right": (master_width - patch_size[0], 0, master_width, patch_size[1]),
"Center": (
(master_width - patch_size[0]) // 2,
(master_height - patch_size[1]) // 2,
(master_width + patch_size[0]) // 2,
(master_height + patch_size[1]) // 2,
),
"Bottom Left": (0, master_height - patch_size[1], patch_size[0], master_height),
"Bottom Right": (
master_width - patch_size[0],
master_height - patch_size[1],
master_width,
master_height,
),
}
scaled_full_image_height = int(master_height * (scaled_full_image_width / master_width))
num_images = len(image_paths)
num_patch_rows = len(patch_definitions)
# Calculate final poster dimensions
poster_width = row_title_width + num_images * (patch_display_size[0] + padding) + padding
total_rows_height = header_height + scaled_full_image_height + num_patch_rows * patch_display_size[1] + histogram_height
total_padding_height = (3 + num_patch_rows) * padding
poster_height = total_rows_height + total_padding_height
# --- Create Poster Canvas ---
poster = Image.new("RGB", (poster_width, poster_height), "white")
draw = ImageDraw.Draw(poster)
# --- 1. Draw Column Headers (Filenames) ---
y_offset = padding
for i, image_path in enumerate(image_paths):
filename = os.path.basename(image_path)
x_offset = row_title_width + i * (patch_display_size[0] + padding)
draw.text((x_offset, y_offset), filename, fill="black", font=header_font)
y_offset += header_height
# --- 2. Draw Row 1: Scaled Full Images ---
draw.text((padding, y_offset + scaled_full_image_height // 2), "Full View", fill="black", font=title_font)
for i, image_path in enumerate(image_paths):
with Image.open(image_path) as img:
img.thumbnail((scaled_full_image_width, scaled_full_image_height))
x_offset = row_title_width + i * (patch_display_size[0] + padding)
poster.paste(img, (x_offset, y_offset))
y_offset += scaled_full_image_height + padding
# --- 3. Draw Patch Rows ---
for patch_name, patch_area in patch_definitions.items():
draw.text((padding, y_offset + patch_display_size[1] // 2), patch_name, fill="black", font=title_font)
for i, image_path in enumerate(image_paths):
with Image.open(image_path) as img:
patch = img.crop(patch_area)
zoomed_patch = patch.resize(patch_display_size, Image.Resampling.LANCZOS)
x_offset = row_title_width + i * (patch_display_size[0] + padding)
poster.paste(zoomed_patch, (x_offset, y_offset))
# Add a border for clarity
draw.rectangle(
(x_offset, y_offset, x_offset + patch_display_size[0], y_offset + patch_display_size[1]),
outline="gray", width=1
)
y_offset += patch_display_size[1] + padding
# --- 4. Draw Final Row: Histograms ---
draw.text((padding, y_offset + histogram_height // 2), "Histogram", fill="black", font=title_font)
for i, image_path in enumerate(image_paths):
histogram_path = f"temp_hist_{i}.png"
with Image.open(image_path) as img:
luminance_data = np.array(img.convert("L"))
plt.figure(figsize=(6, 3))
plt.hist(luminance_data.ravel(), bins=256, range=[0, 256], color='gray', ec='gray')
plt.title("Luminance")
plt.xlabel("Pixel Intensity")
plt.ylabel("Frequency")
plt.tight_layout()
plt.savefig(histogram_path)
plt.close()
with Image.open(histogram_path) as hist_img:
hist_img.thumbnail((patch_display_size[0], histogram_height))
x_offset = row_title_width + i * (patch_display_size[0] + padding)
poster.paste(hist_img, (x_offset, y_offset))
os.remove(histogram_path)
# --- Save Final Poster ---
poster.save(output_path)
print(f"Advanced comparison poster saved to {output_path}")
if __name__ == '__main__':
# --- Example Usage ---
# This block creates a set of dummy high-resolution images to demonstrate the script.
test_dir = "high_res_test_images"
if not os.path.exists(test_dir):
os.makedirs(test_dir)
# Using 4000x3000 as a stand-in for "high resolution" to keep the example fast.
# The script logic works identically for 50MP+ images.
width, height = 4000, 3000
# list .jpg in dir
jpgdir = '/home/dubey/projects/filmsim/test_images/v1.4/05.DNG/'
image_files = [os.path.join(jpgdir, f) for f in os.listdir(jpgdir) if f.endswith('.jpg')]
# --- Generate the poster ---
# For high-res images, a larger patch size from the source is better.
create_advanced_comparison_poster(image_files, patch_size=(1000, 1000), zoom_level=2.5)

147
debug_stitch.py Normal file
View File

@ -0,0 +1,147 @@
import argparse
import re
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# --- Configuration ---
PADDING = 40
FONT_SIZE = 48
FONT_COLOR = "black"
ARROW_COLOR = "black"
BACKGROUND_COLOR = "white"
ARROW_WIDTH_RATIO = 0.3
ARROW_HEIGHT_RATIO = 0.1
def parse_filename(filepath: Path):
"""Extracts the step number and name from a filename."""
# Pattern for standard steps like '..._02_log_exposure_RGB.jpg'
match = re.search(r'_(\d+)_([a-zA-Z0-9_]+?)_RGB\.', filepath.name)
if match:
step_name = match.group(2).replace('_', ' ').title()
return int(match.group(1)), step_name
# Fallback pattern for the first input image like '..._input_linear_sRGB.jpg'
match_input = re.search(r'_input_([a-zA-Z0-9_]+?)_sRGB\.', filepath.name)
if match_input:
step_name = f"Input {match_input.group(1).replace('_', ' ').title()}"
return 0, step_name
# If no pattern matches, return a generic name
return 999, filepath.stem.replace('_', ' ').title()
def create_arrow(width: int, height: int, color: str) -> Image.Image:
"""Creates a right-pointing arrow image with a transparent background."""
arrow_img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(arrow_img)
shaft_width = width * 0.7
rect_start_y = (height // 2) - (height // 10)
rect_height = max(1, height // 5)
draw.rectangle([(0, rect_start_y), (shaft_width, rect_start_y + rect_height)], fill=color)
draw.polygon([(shaft_width, 0), (width, height // 2), (shaft_width, height)], fill=color)
return arrow_img
def main():
parser = argparse.ArgumentParser(
description="Create a visual pipeline of image processing steps with diffs.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("input_dir", type=str, help="Directory containing the input and diff images.")
parser.add_argument("output_file", type=str, help="Path for the final combined image.")
parser.add_argument(
"--scale", type=float, default=0.25,
help="Scale factor for the main pipeline images (e.g., 0.25 for 25%%). Default is 0.25."
)
parser.add_argument(
"--diff-scale", type=float, default=0.15,
help="Scale factor for the smaller diff images. Default is 0.15."
)
args = parser.parse_args()
input_path = Path(args.input_dir)
if not input_path.is_dir():
print(f"Error: Input directory '{args.input_dir}' not found.")
return
# 1. Find and sort all images
# --- THIS IS THE FIX ---
# Use a more general glob to find all .jpg files for the pipeline
pipeline_images_raw = list(input_path.glob("*.jpg"))
diff_images_raw = list(input_path.glob("diff_*_RGB.png"))
if not pipeline_images_raw:
print("Error: No pipeline images (*.jpg) found in the directory.")
return
# Sort files alphabetically by their full name, which mimics 'ls -1' behavior.
pipeline_images_sorted = sorted(pipeline_images_raw)
diff_images_sorted = sorted(diff_images_raw)
print("Found and sorted the following pipeline images:")
for p in pipeline_images_sorted:
print(f" - {p.name}")
# 2. Prepare images and assets
with Image.open(pipeline_images_sorted[0]) as img:
orig_w, orig_h = img.size
img_w, img_h = int(orig_w * args.scale), int(orig_h * args.scale)
diff_w, diff_h = int(orig_w * args.diff_scale), int(orig_h * args.scale)
pipeline_images = [Image.open(p).resize((img_w, img_h), Image.Resampling.LANCZOS) for p in pipeline_images_sorted]
diff_images = [Image.open(p).resize((diff_w, diff_h), Image.Resampling.LANCZOS) for p in diff_images_sorted]
arrow_w, arrow_h = int(img_w * ARROW_WIDTH_RATIO), int(img_h * ARROW_HEIGHT_RATIO)
arrow = create_arrow(arrow_w, arrow_h, ARROW_COLOR)
try:
font = ImageFont.truetype("arial.ttf", FONT_SIZE)
except IOError:
print("Arial font not found, using default font.")
font = ImageFont.load_default()
# 3. Calculate canvas size
num_steps = len(pipeline_images)
gap_width = max(arrow_w, diff_w) + PADDING
total_width = (num_steps * img_w) + ((num_steps - 1) * gap_width) + (2 * PADDING)
total_height = PADDING + FONT_SIZE + PADDING + img_h + PADDING + diff_h + PADDING
# 4. Create the final canvas and draw everything
canvas = Image.new("RGB", (total_width, total_height), BACKGROUND_COLOR)
draw = ImageDraw.Draw(canvas)
y_text = PADDING
y_pipeline = y_text + FONT_SIZE + PADDING
y_arrow = y_pipeline + (img_h // 2) - (arrow_h // 2)
y_diff = y_pipeline + img_h + PADDING
current_x = PADDING
for i, p_img in enumerate(pipeline_images):
canvas.paste(p_img, (current_x, y_pipeline))
_, step_name = parse_filename(pipeline_images_sorted[i])
if step_name:
text_bbox = draw.textbbox((0, 0), step_name, font=font)
text_w = text_bbox[2] - text_bbox[0]
draw.text((current_x + (img_w - text_w) // 2, y_text), step_name, font=font, fill=FONT_COLOR)
if i < num_steps - 1:
gap_start_x = current_x + img_w
arrow_x = gap_start_x + (gap_width - arrow_w) // 2
canvas.paste(arrow, (arrow_x, y_arrow), mask=arrow)
if i < len(diff_images):
diff_x = gap_start_x + (gap_width - diff_w) // 2
canvas.paste(diff_images[i], (diff_x, y_diff))
current_x += img_w + gap_width
# 5. Save the final image
canvas.save(args.output_file, quality=95)
print(f"\nPipeline image successfully created at '{args.output_file}'")
if __name__ == "__main__":
main()

1428
filmcolor

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,57 @@ import imageio.v3 as iio
from scipy.integrate import quad
from scipy.signal.windows import gaussian # For creating Gaussian kernel
import rawpy
import math
def get_grain_parameters(width, height, iso):
"""
Calculates mu_r and sigma for the film grain script based on image
dimensions (width, height) and target ISO.
Args:
width (int): The width of the source image in pixels.
height (int): The height of the source image in pixels.
iso (float): The target film ISO to simulate (e.g., 100, 400, 3200).
Returns:
tuple: A tuple containing the calculated (mu_r, sigma) for the script.
"""
# --- Baseline Parameters (calibrated for a 24MP image @ ISO 400) ---
# A 24MP image (e.g., 6000x4000) has 24,000,000 pixels.
PIXELS_BASE = 24_000_000.0
ISO_BASE = 400.0
MU_R_BASE = 0.15
SIGMA_BASE = 0.5
# --- Scaling Exponents (Artistically chosen for a natural feel) ---
# The exponent for mu_r is larger than for sigma to ensure that
# grain intensity (related to mu_r²/sigma²) increases with ISO.
ISO_EXPONENT_MU = 0.4
ISO_EXPONENT_SIGMA = 0.3
# Clamp ISO to a reasonable range to avoid extreme/invalid values
iso = max(64.0, min(iso, 8000.0))
# 1. Calculate the total number of pixels in the actual image
pixels_actual = float(width * height)
# 2. Calculate the resolution scaler
# This scales parameters based on the image's linear dimensions (sqrt of area)
# relative to the 24MP baseline.
resolution_scaler = math.sqrt(pixels_actual / PIXELS_BASE)
print(f"Resolution scaler: {resolution_scaler:.4f} (for {width}x{height} image)")
# 3. Calculate the ISO scaler
iso_ratio = iso / ISO_BASE
iso_scaler_mu = iso_ratio ** ISO_EXPONENT_MU
iso_scaler_sigma = iso_ratio ** ISO_EXPONENT_SIGMA
print(f"ISO scaler: μ = {iso_scaler_mu:.4f}, σ = {iso_scaler_sigma:.4f} (for ISO {iso})")
# 4. Calculate the final parameters by applying both scalers
final_mu_r = MU_R_BASE * resolution_scaler * iso_scaler_mu
final_sigma = SIGMA_BASE * resolution_scaler * iso_scaler_sigma
return (final_mu_r, final_sigma)
wp.init()
@ -239,7 +290,7 @@ def create_gaussian_kernel_2d(sigma, radius):
return kernel_2d.flatten().astype(np.float32)
def render_film_grain(image_path, mu_r, sigma_filter, output_path, seed=42, mono=False):
def render_film_grain(image_path, iso, output_path, seed=42, mono=False):
try:
if image_path.lower().endswith('.arw') or image_path.lower().endswith('.dng'):
# Use rawpy for TIFF images to handle metadata correctly
@ -276,6 +327,7 @@ def render_film_grain(image_path, mu_r, sigma_filter, output_path, seed=42, mono
img_np_float = img_np.astype(np.float32)
height, width, channels = img_np_float.shape
mu_r, sigma_filter = get_grain_parameters(width, height, iso)
print(f"Input image: {width}x{height}x{channels}")
print(f"Parameters: μr = {mu_r}, σ_filter = {sigma_filter}")
@ -399,13 +451,10 @@ if __name__ == "__main__":
parser.add_argument("input_image", help="Path to the input image (TIFF, PNG, JPG, or RAW (ARW/DNG) format)")
parser.add_argument("output_image", help="Path to save the output image (TIFF (16-bit), PNG, JPG format)")
parser.add_argument(
"--mu_r", type=float, default=0.1, help="Mean grain radius (relative to pixel size)"
)
parser.add_argument(
"--sigma",
type=float,
default=0.8,
help="Standard deviation of the Gaussian Filter for noise blurring (sigma_filter).",
"--iso",
type=int,
default=400,
help="Target film ISO to simulate (e.g., 100, 400, 1600).",
)
parser.add_argument(
"--seed", type=int, default=42, help="Random seed for noise generation"
@ -416,17 +465,7 @@ if __name__ == "__main__":
args = parser.parse_args()
if args.mu_r <= 0:
print("Warning: mu_r should be positive. Using default 0.1")
args.mu_r = 0.1
if args.sigma <= 0:
print("Warning: sigma_filter should be positive. Using default 0.8")
args.sigma = 0.8
if args.sigma < 3 * args.mu_r:
print(
f"Warning: sigma_filter ({args.sigma}) is less than 3*mu_r ({3 * args.mu_r:.2f}). Approximations in the model might be less accurate."
)
render_film_grain(
args.input_image, args.mu_r, args.sigma, args.output_image, args.seed, args.mono
args.input_image, args.iso, args.output_image, args.seed, args.mono
)

View File

@ -315,7 +315,7 @@ def negadoctor_process(img_aces_negative: np.ndarray,
compressed_highlights = soft_clip_param + (1.0 - e_to_gamma) * soft_clip_comp
output_pixels = np.where(print_gamma > soft_clip_param, compressed_highlights, print_gamma)
return np.clip(output_pixels, 0.0, 1.0) # Final clip to 0-1 range
return np.clip(output_pixels, 0.0, None) # Final clip to 0-Inf range
# --- Main Execution ---
@ -371,10 +371,26 @@ if __name__ == "__main__":
print("Converting to ACEScg...")
# img_linear_srgb = colour.gamma_correct(img_float, 1/2.2, 'ITU-R BT.709') # Approximate sRGB EOTF decoding
img_linear_srgb = colour.models.eotf_sRGB(img_float) # More accurate sRGB EOTF decoding
img_acescg = colour.RGB_to_RGB(img_linear_srgb,
colour.models.RGB_COLOURSPACE_sRGB,
colour.models.RGB_COLOURSPACE_ACESCG)
img_acescg = np.clip(img_acescg, 0.0, None) # ACEScg can have values > 1.0 for very bright sources
# Calculate the full transformation matrix from linear sRGB to ACEScg.
# This includes chromatic adaptation from sRGB's D65 whitepoint
# to ACEScg's D60 whitepoint, which is crucial for accuracy.
sRGB_cs = colour.models.RGB_COLOURSPACE_sRGB
ACEScg_cs = colour.models.RGB_COLOURSPACE_ACESCG
# colour.matrix_RGB_to_RGB computes the combined matrix: M_XYZ_to_ACEScg @ M_CAT @ M_sRGB_to_XYZ
# This matrix is cached by colour-science after the first call for efficiency.
srgb_to_acescg_matrix = colour.matrix_RGB_to_RGB(sRGB_cs, ACEScg_cs) # Shape: (3, 3)
# Apply the transformation using NumPy's matrix multiplication operator @.
# img_linear_srgb has shape (H, W, 3).
# srgb_to_acescg_matrix.T also has shape (3, 3).
# The @ operator performs (H, W, 3) @ (3, 3) -> (H, W, 3),
# effectively applying the 3x3 matrix to each 3-element RGB vector.
# This is generally highly optimized and avoids explicit reshape calls.
img_acescg = img_linear_srgb @ srgb_to_acescg_matrix.T
# ACEScg space can legitimately have values outside [0,1] for bright, saturated colors.
img_acescg = np.clip(img_acescg, 0.0, None)
print(f"Image in ACEScg: shape: {img_acescg.shape}, min: {img_acescg.min():.4f}, max: {img_acescg.max():.4f}, mean: {img_acescg.mean():.4f}")

466
filmscanv2 Executable file
View File

@ -0,0 +1,466 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "numpy",
# "scipy",
# "Pillow",
# "imageio",
# "rawpy",
# "colour-science",
# ]
# ///
#!/usr/bin/env python
#!/usr/bin/env python
import argparse
import sys
import numpy as np
import imageio.v2 as iio
import colour
from scipy.signal import convolve2d
from scipy.ndimage import gaussian_filter1d
from scipy.signal import find_peaks, savgol_filter
from scipy.ndimage import sobel
# --- Color Space Conversion ---
def to_acescg(image, input_colorspace='sRGB'):
"""Converts an image from a specified colorspace to ACEScg."""
return colour.RGB_to_RGB(image, colour.models.RGB_COLOURSPACES[input_colorspace], colour.models.RGB_COLOURSPACES['ACEScg'])
def from_acescg(image, output_colorspace='sRGB'):
"""Converts an image from ACEScg to a specified colorspace."""
return colour.RGB_to_RGB(image, colour.models.RGB_COLOURSPACES['ACEScg'], colour.models.RGB_COLOURSPACES[output_colorspace])
# --- Image Processing ---
def _analyze_profile(profile, prominence, width):
"""Helper function to find the most prominent peak in a 1D gradient profile."""
if profile.size == 0:
return None
# Smooth the profile to reduce noise. Window must be odd and less than profile size.
window_length = min(51, len(profile) // 2 * 2 + 1)
if window_length < 5: # savgol_filter requires window_length > polyorder
smoothed_profile = profile
else:
smoothed_profile = savgol_filter(profile, window_length=window_length, polyorder=2)
# Find all peaks that stand out from the baseline.
peaks, properties = find_peaks(smoothed_profile, prominence=prominence, width=width)
if len(peaks) == 0:
return None
# Return the index of the most prominent peak.
most_prominent_peak_index = np.argmax(properties['prominences'])
return peaks[most_prominent_peak_index]
def detect_and_crop_border_gradient(image, border_percent=5, prominence=5.0, min_width=2):
"""
Detects film edges using a directional gradient method, robust to complex borders.
"""
# 1. Convert to grayscale for gradient analysis
luminosity_weights = np.array([0.2126, 0.7152, 0.0722])
image_gray = np.dot(image, luminosity_weights)
height, width = image_gray.shape
# 2. Calculate directional gradients once for the entire image
grad_y = sobel(image_gray, axis=0) # For horizontal lines (top, bottom)
grad_x = sobel(image_gray, axis=1) # For vertical lines (left, right)
coords = {}
search_w = int(width * border_percent / 100)
search_h = int(height * border_percent / 100)
# 3. Analyze each border
# Left Edge (Dark -> Light transition => positive grad_x)
left_profile = np.sum(np.maximum(0, grad_x[:, :search_w]), axis=0)
left_coord = _analyze_profile(left_profile, prominence, min_width)
coords['left'] = left_coord if left_coord is not None else 0
# Right Edge (Light -> Dark transition => negative grad_x)
right_profile = np.sum(np.maximum(0, -grad_x[:, -search_w:]), axis=0)
right_coord = _analyze_profile(right_profile[::-1], prominence, min_width)
coords['right'] = (width - 1 - right_coord) if right_coord is not None else (width - 1)
# Top Edge (Dark -> Light transition => positive grad_y)
top_profile = np.sum(np.maximum(0, grad_y[:search_h, :]), axis=1)
top_coord = _analyze_profile(top_profile, prominence, min_width)
coords['top'] = top_coord if top_coord is not None else 0
# Bottom Edge (Light -> Dark transition => negative grad_y)
bottom_profile = np.sum(np.maximum(0, -grad_y[-search_h:, :]), axis=1)
bottom_coord = _analyze_profile(bottom_profile[::-1], prominence, min_width)
coords['bottom'] = (height - 1 - bottom_coord) if bottom_coord is not None else (height - 1)
l, t, r, b = map(int, [coords['left'], coords['top'], coords['right'], coords['bottom']])
if not (l < r and t < b):
print("Warning: Gradient border detection failed. Using full image.", file=sys.stderr)
film_base_color = np.median(image.reshape(-1, 3), axis=0)
return image, film_base_color
print(f"Detected image box: (left, top, right, bottom) = ({l}, {t}, {r}, {b})")
# 4. Sample film base color from the border regions
mask = np.zeros(image.shape[:2], dtype=bool)
mask[t:b+1, l:r+1] = True
border_pixels = image[~mask]
if border_pixels.size == 0:
print("Warning: Border detected, but no pixels to sample. Using image median.", file=sys.stderr)
film_base_color = np.median(image[t:b+1, l:r+1].reshape(-1, 3), axis=0)
else:
film_base_color = np.median(border_pixels.reshape(-1, 3), axis=0)
# 5. Crop and return
cropped_image = image[t:b+1, l:r+1]
return cropped_image, film_base_color
def invert_negative(image, params):
"""
Inverts a negative image based on RawTherapee's filmnegative module logic.
"""
ref_in = params['refInput'] + 1e-9 # Add epsilon to avoid division by zero
ref_out = params['refOutput']
rexp = -(params['greenExp'] * params['redRatio'])
gexp = -params['greenExp']
bexp = -(params['greenExp'] * params['blueRatio'])
rmult = ref_out[0] / (ref_in[0] ** rexp)
gmult = ref_out[1] / (ref_in[1] ** gexp)
bmult = ref_out[2] / (ref_in[2] ** bexp)
inverted_image = image.copy() + 1e-9
inverted_image[:, :, 0] = rmult * (inverted_image[:, :, 0] ** rexp)
inverted_image[:, :, 1] = gmult * (inverted_image[:, :, 1] ** gexp)
inverted_image[:, :, 2] = bmult * (inverted_image[:, :, 2] ** bexp)
return np.clip(inverted_image, 0.0, None)
def negative_auto_exposure(image, target_percentile=50, highlight_percentile=99.5, highlight_preservation=0.85):
"""
Automatically adjusts the exposure of a negative image to maximize dynamic range while preserving highlights.
Args:
image: Input image in linear RGB space (ACEScg) NEGATIVE
target_percentile: Percentile to target for middle exposure (default: 50)
highlight_percentile: Percentile to consider as highlights (default: 99.5)
highlight_preservation: Controls how much to preserve highlights (0-1, default: 0.85)
Returns:
Adjusted image with optimized dynamic range
"""
# Calculate luminance using standard coefficients for linear light
luminance = 0.2126 * image[:,:,0] + 0.7152 * image[:,:,1] + 0.0722 * image[:,:,2]
# Analyze histogram
hist_values = luminance.flatten()
# Get key luminance values from histogram
mid_value = np.percentile(hist_values, target_percentile)
highlight_value = np.percentile(hist_values, highlight_percentile)
# Target middle gray for optimal exposure (standard for scene-referred linear)
target_middle = 0.18
# Calculate exposure factor for middle values
middle_exposure_factor = target_middle / (mid_value + 1e-9)
# Calculate highlight protection factor
highlight_target = 0.9 # Target highlights to be at 90% of range
highlight_exposure_factor = highlight_target / (highlight_value * middle_exposure_factor + 1e-9)
# Blend between middle and highlight exposure factor based on preservation setting
final_exposure_factor = middle_exposure_factor * (1 - highlight_preservation) + \
(middle_exposure_factor * highlight_exposure_factor) * highlight_preservation
# Apply exposure adjustment
adjusted_image = image * final_exposure_factor
return np.clip(adjusted_image, 0.0, None) # Ensure no negative values but allow overflow for highlights
def auto_exposure(image, target_percentile=50, highlight_percentile=99.5, highlight_preservation=0.85):
"""
Automatically adjusts the exposure of an image to maximize dynamic range while preserving highlights.
Args:
image: Input image in linear RGB space (ACEScg)
target_percentile: Percentile to target for middle exposure (default: 50)
highlight_percentile: Percentile to consider as highlights (default: 99.5)
highlight_preservation: Controls how much to preserve highlights (0-1, default: 0.85)
Returns:
Adjusted image with optimized dynamic range
"""
# Calculate luminance using standard coefficients for linear light
luminance = 0.2126 * image[:,:,0] + 0.7152 * image[:,:,1] + 0.0722 * image[:,:,2]
# Analyze histogram
hist_values = luminance.flatten()
# Get key luminance values from histogram
mid_value = np.percentile(hist_values, target_percentile)
highlight_value = np.percentile(hist_values, highlight_percentile)
# Target middle gray for optimal exposure (standard for scene-referred linear)
target_middle = 0.18
# Calculate exposure factor for middle values
middle_exposure_factor = target_middle / (mid_value + 1e-9)
# Calculate highlight protection factor
highlight_target = 0.9 # Target highlights to be at 90% of range
highlight_exposure_factor = highlight_target / (highlight_value * middle_exposure_factor + 1e-9)
# Blend between middle and highlight exposure factor based on preservation setting
final_exposure_factor = middle_exposure_factor * (1 - highlight_preservation) + \
(middle_exposure_factor * highlight_exposure_factor) * highlight_preservation
# Apply exposure adjustment
adjusted_image = image * final_exposure_factor
# Apply subtle S-curve for enhanced contrast while preserving highlights
# Convert to log space for easier manipulation
log_image = np.log2(adjusted_image + 1e-9)
# Apply soft contrast enhancement
contrast_strength = 0.15
log_image = log_image * (1 + contrast_strength) - np.mean(log_image) * contrast_strength
# Convert back to linear space
enhanced_image = np.power(2, log_image)
# Ensure no negative values, but allow overflow for highlight processing later
return np.clip(enhanced_image, 0.0, None)
def auto_exposure_pec(linear_image, **kwargs):
"""
Implements Practical Exposure Correction (PEC) on a linear image.
Automatically determines the correction mode based on image brightness.
"""
params = {
'K': kwargs.get('K', 3),
'c_under': kwargs.get('c_under', 1.0),
'c_over': kwargs.get('c_over', 0.6),
'target_lum': kwargs.get('target_lum', 0.18)
}
# Auto-detect mode
luminosity_weights = np.array([0.2126, 0.7152, 0.0722])
mean_luminance = np.mean(np.dot(linear_image, luminosity_weights))
if mean_luminance < params['target_lum']:
mode, c = 'underexposure', params['c_under']
op = np.add
print(f"Image appears underexposed (mean lum: {mean_luminance:.3f}). Applying PEC in '{mode}' mode.")
else:
mode, c = 'overexposure', params['c_over']
op = np.subtract
print(f"Image appears overexposed (mean lum: {mean_luminance:.3f}). Applying PEC in '{mode}' mode.")
y = linear_image.astype(np.float64)
adversarial_func = lambda z: c * z * (1 - z)
g_y = op(y, adversarial_func(y))
x_k = g_y.copy()
# The PEC iterative scheme (T=1, as recommended)
for _ in range(params['K']):
compensation = adversarial_func(x_k)
x_k = op(g_y, compensation)
return x_k
def _rgb_to_yuv_huo(image_rgb: np.ndarray) -> np.ndarray:
"""Converts RGB to the paper's specific YUV space."""
matrix = np.array([[0.299, -0.299, 0.701], [0.587, -0.587, -0.587], [0.114, 0.886, -0.114]])
return image_rgb.astype(np.float64) @ matrix
def _k_function(error: float, a: float, b: float) -> float:
"""Non-linear error weighting function K(x) from Eq. 16."""
abs_error, sign = np.abs(error), np.sign(error)
if abs_error >= a: return 2.0 * sign
elif abs_error >= b: return 1.0 * sign
else: return 0.0
def white_balance_huo(image_float: np.ndarray, **kwargs):
"""Performs iterative white balance based on the Huo et al. 2006 paper."""
params = {
't_threshold': kwargs.get('t_threshold', 0.1321),
'mu': kwargs.get('mu', 0.0312),
'a': kwargs.get('a', 0.8 / 255.0),
'b': kwargs.get('b', 0.15 / 255.0),
'max_iter': kwargs.get('max_iter', 16),
}
gains = np.array([1.0, 1.0, 1.0], dtype=np.float64)
print("Starting iterative white balance adjustment...")
for i in range(params['max_iter']):
balanced_image = np.clip(image_float * gains, 0.0, 1.0)
yuv_image = _rgb_to_yuv_huo(balanced_image)
Y, U, V = yuv_image[..., 0], yuv_image[..., 1], yuv_image[..., 2]
luminance_mask = (Y > 0.1) & (Y < 0.95)
if not np.any(luminance_mask):
print(f"Iteration {i+1}: No pixels in valid luminance range. Stopping."); break
gray_mask_indices = (np.abs(U[luminance_mask]) + np.abs(V[luminance_mask])) / Y[luminance_mask] < params['t_threshold']
gray_points_U = U[luminance_mask][gray_mask_indices]
gray_points_V = V[luminance_mask][gray_mask_indices]
if gray_points_U.size < 100:
print(f"Iteration {i+1}: Not enough gray points found ({gray_points_U.size}). Stopping."); break
u_mean, v_mean = np.mean(gray_points_U), np.mean(gray_points_V)
if np.abs(u_mean) < params['b'] and np.abs(v_mean) < params['b']:
print(f"Iteration {i+1}: Converged. u_mean={u_mean:.4f}, v_mean={v_mean:.4f}"); break
error, channel_idx, channel_name = (-u_mean, 2, "B") if np.abs(u_mean) > np.abs(v_mean) else (-v_mean, 0, "R")
adjustment = params['mu'] * _k_function(error, params['a'], params['b'])
gains[channel_idx] += adjustment
print(f"Iter {i+1:2d}: Adjusting {channel_name}-gain. u_mean={u_mean:.4f}, v_mean={v_mean:.4f}, Adj={adjustment:+.4f}")
print(f"Final gains: R={gains[0]:.4f}, G={gains[1]:.4f}, B={gains[2]:.4f}")
return image_float * gains
def white_balance_gray_world(image):
"""
Performs white balancing using the Gray World assumption.
"""
r_avg, g_avg, b_avg = np.mean(image, axis=(0, 1))
avg_lum = (r_avg + g_avg + b_avg) / 3.0
r_gain = avg_lum / (r_avg + 1e-9)
g_gain = avg_lum / (g_avg + 1e-9)
b_gain = avg_lum / (b_avg + 1e-9)
wb_image = image.copy()
wb_image[:, :, 0] *= r_gain
wb_image[:, :, 1] *= g_gain
wb_image[:, :, 2] *= b_gain
return wb_image
# --- Main Execution ---
def main():
parser = argparse.ArgumentParser(
description="Converts a film negative to a positive image. Requires numpy, imageio, and colour-science.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument('input_image', help="Path to the input negative image file (e.g., TIFF, PNG, JPG).")
parser.add_argument('output_image', help="Path to save the output positive image file.")
parser.add_argument('--border', action='store_true', help="Indicates the image has a film border to sample the base color from.")
parser.add_argument('--no-crop', dest='crop', action='store_false', help="Disables cropping of the film border when --border is used.")
parser.add_argument('--no-wb', dest='white_balance', action='store_false', help="Disables the automatic white balance step.")
parser.add_argument('--no-auto-exposure', dest='auto_exposure', action='store_false', help="Disables the automatic exposure adjustment step.")
parser.add_argument('--prominence', type=float, default=5.0, help="[Border] Peak prominence for edge detection.")
parser.add_argument('--awb-t', type=float, default=0.1321, help="[AWB] Gray point detection threshold `T`.")
parser.add_argument('--awb-mu', type=float, default=0.0312, help="[AWB] Adjustment step size `mu`.")
parser.add_argument('--pec-k', type=int, default=3, help="[Exposure] Number of inner loop iterations K.")
parser.add_argument('--pec-target-lum', type=float, default=0.18, help="[Exposure] Target middle gray for auto mode detection.")
args = parser.parse_args()
# 1. Load Image
try:
image_raw = iio.imread(args.input_image)
except FileNotFoundError:
print(f"Error: Input file not found at {args.input_image}", file=sys.stderr)
return
# Convert to float (0.0 to 1.0) for processing
if image_raw.dtype == np.uint16:
image_fp = image_raw.astype(np.float32) / 65535.0
elif image_raw.dtype == np.uint8:
image_fp = image_raw.astype(np.float32) / 255.0
else: # Handle other types like float
image_fp = image_raw.astype(np.float32)
if image_fp.max() > 1.0:
image_fp /= image_fp.max()
# Handle grayscale and alpha channels using numpy
if image_fp.ndim == 2:
print("Input is grayscale, converting to RGB.", file=sys.stderr)
image_fp = np.stack((image_fp,) * 3, axis=-1)
if image_fp.shape[2] == 4:
print("Input has alpha channel, removing it for processing.", file=sys.stderr)
image_fp = image_fp[:, :, :3]
# 2. Convert to ACEScg Colorspace
image_to_process = None
if args.border:
print("Using marching border detection...")
cropped_image, film_base_color = detect_and_crop_border_gradient(image_fp, prominence=args.prominence)
if args.crop:
print("Cropping border...")
image_fp = cropped_image
print("Converting to ACEScg...")
image_aces = to_acescg(image_fp, 'sRGB')
image_to_process = image_aces
else:
print("No border specified, using image median for base color...")
print("Converting to ACEScg...")
image_aces = to_acescg(image_fp, 'sRGB')
image_to_process = image_aces
h, w, _ = image_to_process.shape
center_crop = image_to_process[h//4:3*h//4, w//4:3*w//4, :]
film_base_color = np.median(center_crop.reshape(-1, 3), axis=0)
print(f"Detected film base color (ACEScg): {film_base_color}")
# 4. Invert Negative
print("Inverting negative...")
inversion_params = {
'greenExp': 1.5,
'redRatio': 2.04 / 1.5,
'blueRatio': 1.29 / 1.5,
'refInput': film_base_color,
'refOutput': np.array([0.05, 0.05, 0.05])
}
positive_image = invert_negative(image_to_process, inversion_params)
# 5. Auto Exposure Adjustment
if args.auto_exposure:
print("Applying automatic exposure adjustment...")
positive_image = auto_exposure_pec(positive_image, K=args.pec_k, target_lum=args.pec_target_lum)
# 5. White Balance
if args.white_balance:
print("Applying white balance...")
awb_params = {'t_threshold': args.awb_t, 'mu': args.awb_mu}
positive_image = white_balance_gray_world(positive_image)
# 6. Convert back from ACEScg and save
print("Converting from ACEScg to sRGB for output...")
output_image_srgb = from_acescg(positive_image, 'sRGB')
output_image_srgb = np.clip(output_image_srgb, 0.0, 1.0)
# 7. Save to file
output_extension = args.output_image.lower().split('.')[-1]
if output_extension in ['tif', 'tiff']:
print("Saving as 16-bit TIFF.")
final_image = (output_image_srgb * 65535.0).astype(np.uint16)
else:
print("Saving as 8-bit image.")
final_image = (output_image_srgb * 255.0).astype(np.uint8)
iio.imwrite(args.output_image, final_image)
print(f"Successfully saved positive image to {args.output_image}")
if __name__ == "__main__":
main()

242
filmscanv3.py Executable file
View File

@ -0,0 +1,242 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "numpy",
# "scipy",
# "Pillow",
# "imageio",
# "colour-science",
# "tifffile",
# ]
# ///
import argparse
import time
import sys
import imageio.v2 as imageio
import numpy as np
import colour
# --- Helper Functions ---
def find_film_base_from_border(linear_image: np.ndarray) -> np.ndarray:
"""
Finds the film base color by sampling the outer 2% border of the image.
This function implements the --border logic. It randomly samples 64 patches
of 8x8 pixels from the defined border area, calculates the mean color of each
patch, and returns the median of these mean colors. Median is used as it's
more robust to outliers (like dust or scratches) than the mean or mode.
Args:
linear_image: The image data in a linear RGB color space (float32).
Returns:
A 1D NumPy array representing the film base color [R, G, B].
"""
print("Finding film base color from image border...")
h, w, _ = linear_image.shape
border_px_h = int(h * 0.02)
border_px_w = int(w * 0.02)
# Define four rectangular regions for the border
regions = [
(0, 0, h, border_px_w), # Left
(0, w - border_px_w, h, w), # Right
(0, 0, border_px_h, w), # Top
(h - border_px_h, 0, h, w), # Bottom
]
patch_size = 8
num_patches = 64
patch_means = []
for _ in range(num_patches):
# Pick a random region and sample a patch from it
top, left, bottom, right = regions[np.random.randint(4)]
# Ensure we don't sample outside the bounds
if (bottom - top <= patch_size) or (right - left <= patch_size):
continue
rand_y = np.random.randint(top, bottom - patch_size)
rand_x = np.random.randint(left, right - patch_size)
patch = linear_image[rand_y:rand_y + patch_size, rand_x:rand_x + patch_size]
patch_means.append(np.mean(patch, axis=(0, 1)))
if not patch_means:
raise ValueError("Could not sample any patches from the border. Check image dimensions and border size.")
# Median is more robust to outliers (dust, scratches) than mean
film_base_color = np.median(patch_means, axis=0)
return film_base_color
def find_film_base_from_darkest(linear_image: np.ndarray) -> np.ndarray:
"""
Finds the film base color by identifying the darkest pixels in the negative.
This function implements the default logic. It calculates the luminance of
the image, identifies the 0.1% darkest pixels, and returns their average color.
Args:
linear_image: The image data in a linear RGB color space (float32).
Returns:
A 1D NumPy array representing the film base color [R, G, B].
"""
print("Finding film base color from darkest part of the image...")
# Using Rec.709 coefficients for luminance calculation is a standard approach
luminance = colour.RGB_luminance(linear_image, primaries='ITU-R BT.709', whitepoint='D65')
# Find the threshold for the darkest 0.1% of pixels
darkest_threshold = np.percentile(luminance, 0.1)
darkest_pixels_mask = luminance <= darkest_threshold
# If no pixels are found (unlikely), relax the threshold
if not np.any(darkest_pixels_mask):
darkest_pixels_mask = luminance <= np.percentile(luminance, 1)
darkest_pixels = linear_image[darkest_pixels_mask]
film_base_color = np.mean(darkest_pixels, axis=0)
return film_base_color
# --- Main Processing Function ---
def main():
parser = argparse.ArgumentParser(
description="A high-performance tool for inverting color negative film scans.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("input_file", help="Path to the 16-bit input TIFF file (sRGB).")
parser.add_argument("output_file", help="Path to save the 16-bit output TIFF file.")
method_group = parser.add_mutually_exclusive_group()
method_group.add_argument(
"--border",
action="store_true",
help="Find film base color by sampling the outer 2%% of the image border."
)
method_group.add_argument(
"--color",
type=str,
help="Specify film base color as a CIEXYZ value (e.g., '0.41,0.35,0.18').\nAssumes D65 standard illuminant."
)
parser.add_argument(
"--wide",
action="store_true",
help="Output in a wide gamut color space (Rec.2020) instead of sRGB."
)
if len(sys.argv) == 1:
parser.print_help(sys.stderr)
sys.exit(1)
args = parser.parse_args()
start_time = time.time()
# 1. Read Image and Normalize
print(f"Reading image: {args.input_file}")
try:
# ImageIO reads TIFFs as (height, width, channels)
img_int16 = imageio.imread(args.input_file)
except FileNotFoundError:
print(f"Error: Input file not found at {args.input_file}")
sys.exit(1)
if img_int16.dtype != np.uint16:
print("Warning: Input image is not 16-bit. Results may have reduced quality.")
# Convert to float (0.0 - 1.0) for processing
img_float = img_int16.astype(np.float32) / 65535.0
# 2. Color Space Conversion (to Linear)
# The input is assumed to be in standard (non-linear) sRGB space.
# All our math must happen in a linear space.
print("Converting from sRGB to linear sRGB...")
srgb_colourspace = colour.models.RGB_COLOURSPACE_sRGB
linear_image = colour.cctf_decoding(img_float, function='sRGB')
# 3. Determine Film Base Color
film_base_color = None
if args.color:
print(f"Using provided CIEXYZ color: {args.color}")
try:
xyz_values = np.array([float(x.strip()) for x in args.color.split(',')])
print(f"Parsed XYZ values: {xyz_values}")
if xyz_values.shape != (3,):
print("Error: --color must be in the format 'X,Y,Z' with three values.")
raise ValueError
# Convert the provided XYZ color to our working linear sRGB space
film_base_color = colour.XYZ_to_RGB(xyz_values, 'sRGB')
except (ValueError, IndexError) as e:
print("Error: Invalid --color format. Please use 'X,Y,Z', e.g., '0.41,0.35,0.18'")
print(e)
sys.exit(1)
elif args.border:
film_base_color = find_film_base_from_border(linear_image)
else:
# Default method if neither --border nor --color is specified
film_base_color = find_film_base_from_darkest(linear_image)
print(f"Determined Film Base Color (Linear RGB): {np.round(film_base_color, 4)}")
# Ensure base color is not black to avoid division by zero
if np.any(film_base_color <= 1e-8):
print("Error: Determined film base color is too dark or black, cannot proceed.")
sys.exit(1)
# 4. Core Inversion Process
print("Performing inversion...")
# Step A: Remove the film mask by dividing by the base color.
# This is equivalent to Photoshop's 'Divide' blend mode.
# It "white balances" the image against the orange mask.
masked_removed = linear_image / film_base_color
# Step B: Invert the image. Based on the principle that exposure = 1 / transmittance.
# Add a small epsilon to prevent division by zero in pure black areas.
epsilon = 1e-8
inverted_image = 1.0 / (masked_removed + epsilon)
# 5. Normalize for Output
# The inverted values are unbounded. We must normalize them into the 0-1 range.
# This is a technical step, not an artistic one like levels. It preserves tonal relationships.
max_val = np.percentile(inverted_image, 99.95) # Avoid blowing out specular highlights
if max_val <= epsilon:
print("Warning: Inverted image is black. Result will be black.")
normalized_linear_positive = np.zeros_like(inverted_image)
else:
normalized_linear_positive = inverted_image / max_val
# Clip any remaining >1 values
normalized_linear_positive = np.clip(normalized_linear_positive, 0.0, 1.0)
# 6. Color Space Conversion (to Output Space)
output_space_name = "Rec.2020" if args.wide else "sRGB"
print(f"Converting linear positive to target space ({output_space_name})...")
if args.wide:
# Convert from linear sRGB primaries to linear Rec.2020 primaries
wide_linear = colour.RGB_to_RGB(
normalized_linear_positive, srgb_colourspace, colour.models.RGB_COLOURSPACE_BT2020
)
# Apply the Rec.2020 gamma curve
final_image_float = colour.cctf_encoding(wide_linear, function='ITU-R BT.2020')
else:
# Just apply the sRGB gamma curve
final_image_float = colour.cctf_encoding(normalized_linear_positive, function='sRGB')
# 7. De-normalize and Save
print(f"Saving to {args.output_file}")
# Convert from float (0-1) back to 16-bit integer (0-65535)
output_image = (np.clip(final_image_float, 0.0, 1.0) * 65535).astype(np.uint16)
imageio.imwrite(args.output_file, output_image)
end_time = time.time()
print(f"\nProcessing complete in {end_time - start_time:.2f} seconds.")
if __name__ == "__main__":
main()

36
hdrtest.py Normal file
View File

@ -0,0 +1,36 @@
import numpy as np
import imageio.v3 as iio
# --- Parameters ---
WIDTH, HEIGHT = 1024, 1024
CENTER_X, CENTER_Y = WIDTH // 2, HEIGHT // 2
RADIUS = 150
BACKGROUND_COLOR = 0.0 # Pure black
# This is the key change: A "super-white" HDR value for the circle.
# A value of 5.0 simulates a very bright light source.
CIRCLE_BRIGHTNESS = 5.0
OUTPUT_FILENAME = "halation_test_hdr.tiff"
# --- Generate the image ---
# Create coordinate grids
y, x = np.mgrid[:HEIGHT, :WIDTH]
# Calculate distance from the center
distance = np.sqrt((x - CENTER_X)**2 + (y - CENTER_Y)**2)
# Create a circular mask
mask = distance <= RADIUS
# Create a 3-channel float image
# Use float32, as it's a standard for HDR images
image_hdr = np.full((HEIGHT, WIDTH, 3), BACKGROUND_COLOR, dtype=np.float32)
# Set the circle area to the super-white value
image_hdr[mask] = [CIRCLE_BRIGHTNESS, CIRCLE_BRIGHTNESS, CIRCLE_BRIGHTNESS]
# --- Save the image ---
# Save as a 32-bit float TIFF to preserve the HDR values
iio.imwrite(OUTPUT_FILENAME, image_hdr)
print(f"✅ Saved HDR test image to '{OUTPUT_FILENAME}'")
print(f" Use this file as the input for your film simulation script.")

138
poster.py Normal file
View File

@ -0,0 +1,138 @@
import argparse
import sys
from PIL import Image, ImageDraw
def create_full_poster(input_path: str, output_path: str):
"""
Generates a poster from a high-resolution source image.
The poster features a 50% resolution version of the main image at the top,
with a complete 3x3 grid of all nine "rule of thirds" 250% zoom patches below it.
"""
try:
# Open the high-resolution source image
with Image.open(input_path) as original_image:
original_image = original_image.convert("RGB")
orig_width, orig_height = original_image.size
print(f"Loaded input image: {input_path} ({orig_width}x{orig_height})")
# --- 1. Define Sizes ---
# Central image is 50% of original
main_image_width = orig_width // 2
main_image_height = orig_height // 2
# Crop area for patches is 1/6th of original width
patch_crop_width = orig_width // 6
patch_crop_height = int(patch_crop_width * (orig_height / orig_width))
# Patches are zoomed to 250% of their cropped size
zoom_factor = 2.5
zoomed_patch_width = int(patch_crop_width * zoom_factor)
zoomed_patch_height = int(patch_crop_height * zoom_factor)
# Define padding values
patch_padding = 25 # Padding between patches in the grid
section_padding = 50 # Padding between the main image and the grid
canvas_padding = 50 # Padding around the entire content
# --- 2. Calculate Layout & Create Canvas ---
# Calculate the dimensions of the 3x3 patch grid
grid_width = (zoomed_patch_width * 3) + (patch_padding * 2)
grid_height = (zoomed_patch_height * 3) + (patch_padding * 2)
# Calculate total canvas dimensions
canvas_width = max(main_image_width, grid_width) + (canvas_padding * 2)
canvas_height = main_image_height + grid_height + section_padding + (canvas_padding * 2)
# Create the blank white canvas
canvas = Image.new('RGB', (canvas_width, canvas_height), 'white')
draw = ImageDraw.Draw(canvas)
print(f"Created poster canvas of size: {canvas_width}x{canvas_height}")
# --- 3. Place Main Image & Draw Highlights ---
# Create the 50% resolution main image
main_image = original_image.resize((main_image_width, main_image_height), Image.Resampling.LANCZOS)
# Position and paste the main image in the top section
main_image_x = (canvas_width - main_image_width) // 2
main_image_y = canvas_padding
canvas.paste(main_image, (main_image_x, main_image_y))
# Define the center points for the Rule of Thirds grid on the ORIGINAL image
thirds_points = [
(orig_width // 6, orig_height // 6), (orig_width // 2, orig_height // 6), (5 * orig_width // 6, orig_height // 6),
(orig_width // 6, orig_height // 2), (orig_width // 2, orig_height // 2), (5 * orig_width // 6, orig_height // 2),
(orig_width // 6, 5 * orig_height // 6), (orig_width // 2, 5 * orig_height // 6), (5 * orig_width // 6, 5 * orig_height // 6),
]
# Draw all 9 highlight boxes on the main image
print("Drawing highlight boxes on main image...")
for center_x, center_y in thirds_points:
crop_left = center_x - (patch_crop_width // 2)
crop_top = center_y - (patch_crop_height // 2)
# Scale coordinates to the 50% main image and add its offset
highlight_x1 = main_image_x + int(crop_left * 0.5)
highlight_y1 = main_image_y + int(crop_top * 0.5)
highlight_x2 = highlight_x1 + int(patch_crop_width * 0.5)
highlight_y2 = highlight_y1 + int(patch_crop_height * 0.5)
draw.rectangle((highlight_x1, highlight_y1, highlight_x2, highlight_y2), outline="red", width=4)
# --- 4. Create and Place all 9 Patches in a Grid ---
print("Generating and placing 9 zoomed patches in a grid...")
grid_origin_x = (canvas_width - grid_width) // 2
grid_origin_y = main_image_y + main_image_height + section_padding
for i, (center_x, center_y) in enumerate(thirds_points):
# Define the crop box on the ORIGINAL image
crop_box = (
center_x - patch_crop_width // 2, center_y - patch_crop_height // 2,
center_x + patch_crop_width // 2, center_y + patch_crop_height // 2
)
# Crop the patch and zoom it
patch = original_image.crop(crop_box)
zoomed_patch = patch.resize((zoomed_patch_width, zoomed_patch_height), Image.Resampling.LANCZOS)
# Determine the patch's position in the 3x3 grid
row, col = divmod(i, 3)
patch_x = grid_origin_x + col * (zoomed_patch_width + patch_padding)
patch_y = grid_origin_y + row * (zoomed_patch_height + patch_padding)
# Paste the patch and draw a border
canvas.paste(zoomed_patch, (patch_x, patch_y))
draw.rectangle(
(patch_x, patch_y, patch_x + zoomed_patch_width, patch_y + zoomed_patch_height),
outline="black",
width=2
)
# --- 5. Save the Final Poster ---
canvas.save(output_path, quality=95)
print(f"\nSuccess! Poster saved to: {output_path}")
except FileNotFoundError:
print(f"Error: The input file was not found at '{input_path}'", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Create a poster with a main image and a full 3x3 grid of zoomed-in 'rule of thirds' patches.",
formatter_class=argparse.RawTextHelpFormatter,
epilog="Example:\n python create_poster_v2.py my_photo.jpg poster_result.png"
)
parser.add_argument("input", help="Path to the high-resolution input image.")
parser.add_argument("output", help="Path for the generated poster image.")
if len(sys.argv) == 1:
parser.print_help(sys.stderr)
sys.exit(1)
args = parser.parse_args()
create_full_poster(args.input, args.output)

167
posterv2.py Normal file
View File

@ -0,0 +1,167 @@
import argparse
import sys
from PIL import Image, ImageDraw
def create_comparison_poster(input_path1: str, input_path2: str, output_path: str):
"""
Generates a comparison poster from two high-resolution source images.
The poster features 50% resolution versions of both main images side-by-side at the top.
Below, it shows a complete 3x3 grid of "rule of thirds" patches. Each grid cell
contains a side-by-side comparison of the 250% zoomed patch from both images.
"""
try:
# Open the two high-resolution source images
with Image.open(input_path1) as original_image1, Image.open(input_path2) as original_image2:
original_image1 = original_image1.convert("RGB")
original_image2 = original_image2.convert("RGB")
# --- Ensure images are the same size for consistent cropping ---
orig_width, orig_height = original_image1.size
if original_image1.size != original_image2.size:
print(f"Warning: Image sizes differ. Resizing second image from {original_image2.size} to {original_image1.size}.")
original_image2 = original_image2.resize(original_image1.size, Image.Resampling.LANCZOS)
print(f"Loaded input image 1: {input_path1} ({orig_width}x{orig_height})")
print(f"Loaded input image 2: {input_path2} ({orig_width}x{orig_height})")
# --- 1. Define Sizes ---
# Main images are 50% of original
main_image_width = orig_width // 2
main_image_height = orig_height // 2
# Crop area for patches is 1/6th of original width
patch_crop_width = orig_width // 6
patch_crop_height = int(patch_crop_width * (orig_height / orig_width))
# Patches are zoomed to 250% of their cropped size
zoom_factor = 2.5
zoomed_patch_width = int(patch_crop_width * zoom_factor)
zoomed_patch_height = int(patch_crop_height * zoom_factor)
# Define padding values
comparison_padding = 10 # Padding between the two patches in a pair
patch_padding = 25 # Padding between patch pairs in the grid
section_padding = 50 # Padding between main images and the grid
canvas_padding = 50 # Padding around the entire content
# --- 2. Calculate Layout & Create Canvas ---
# Calculate the dimensions of the top section (two main images side-by-side)
top_section_width = (main_image_width * 2) + section_padding
# Calculate the dimensions of a single side-by-side comparison patch pair
comparison_pair_width = (zoomed_patch_width * 2) + comparison_padding
# Calculate the dimensions of the full 3x3 patch grid
grid_width = (comparison_pair_width * 3) + (patch_padding * 2)
grid_height = (zoomed_patch_height * 3) + (patch_padding * 2)
# Calculate total canvas dimensions
canvas_width = max(top_section_width, grid_width) + (canvas_padding * 2)
canvas_height = main_image_height + grid_height + section_padding + (canvas_padding * 2)
# Create the blank white canvas
canvas = Image.new('RGB', (canvas_width, canvas_height), 'white')
draw = ImageDraw.Draw(canvas)
print(f"Created poster canvas of size: {canvas_width}x{canvas_height}")
# --- 3. Place Main Images & Draw Highlights ---
# Create the 50% resolution main images
main_image1 = original_image1.resize((main_image_width, main_image_height), Image.Resampling.LANCZOS)
main_image2 = original_image2.resize((main_image_width, main_image_height), Image.Resampling.LANCZOS)
# Position and paste the main images in the top section
top_section_x_start = (canvas_width - top_section_width) // 2
main_image_y = canvas_padding
main_image1_x = top_section_x_start
main_image2_x = top_section_x_start + main_image_width + section_padding
canvas.paste(main_image1, (main_image1_x, main_image_y))
canvas.paste(main_image2, (main_image2_x, main_image_y))
# Define the center points for the Rule of Thirds grid on the ORIGINAL image
thirds_points = [
(orig_width // 6, orig_height // 6), (orig_width // 2, orig_height // 6), (5 * orig_width // 6, orig_height // 6),
(orig_width // 6, orig_height // 2), (orig_width // 2, orig_height // 2), (5 * orig_width // 6, orig_height // 2),
(orig_width // 6, 5 * orig_height // 6), (orig_width // 2, 5 * orig_height // 6), (5 * orig_width // 6, 5 * orig_height // 6),
]
# Draw all 9 highlight boxes on BOTH main images
print("Drawing highlight boxes on main images...")
for main_img_x_offset in [main_image1_x, main_image2_x]:
for center_x, center_y in thirds_points:
crop_left = center_x - (patch_crop_width // 2)
crop_top = center_y - (patch_crop_height // 2)
# Scale coordinates to the 50% main image and add its offset
hl_x1 = main_img_x_offset + int(crop_left * 0.5)
hl_y1 = main_image_y + int(crop_top * 0.5)
hl_x2 = hl_x1 + int(patch_crop_width * 0.5)
hl_y2 = hl_y1 + int(patch_crop_height * 0.5)
draw.rectangle((hl_x1, hl_y1, hl_x2, hl_y2), outline="red", width=4)
# --- 4. Create and Place all 9 Comparison Patch Pairs in a Grid ---
print("Generating and placing 9 zoomed patch pairs in a grid...")
grid_origin_x = (canvas_width - grid_width) // 2
grid_origin_y = main_image_y + main_image_height + section_padding
for i, (center_x, center_y) in enumerate(thirds_points):
# Define the crop box on the ORIGINAL images (it's the same for both)
crop_box = (
center_x - patch_crop_width // 2, center_y - patch_crop_height // 2,
center_x + patch_crop_width // 2, center_y + patch_crop_height // 2
)
# Crop the patch from each image and zoom it
patch1 = original_image1.crop(crop_box)
zoomed_patch1 = patch1.resize((zoomed_patch_width, zoomed_patch_height), Image.Resampling.LANCZOS)
patch2 = original_image2.crop(crop_box)
zoomed_patch2 = patch2.resize((zoomed_patch_width, zoomed_patch_height), Image.Resampling.LANCZOS)
# Determine the patch pair's position in the 3x3 grid
row, col = divmod(i, 3)
pair_x_start = grid_origin_x + col * (comparison_pair_width + patch_padding)
patch_y = grid_origin_y + row * (zoomed_patch_height + patch_padding)
# Calculate individual patch coordinates within the pair
patch1_x = pair_x_start
patch2_x = pair_x_start + zoomed_patch_width + comparison_padding
# Paste the patches and draw borders
canvas.paste(zoomed_patch1, (patch1_x, patch_y))
draw.rectangle((patch1_x, patch_y, patch1_x + zoomed_patch_width, patch_y + zoomed_patch_height), outline="black", width=2)
canvas.paste(zoomed_patch2, (patch2_x, patch_y))
draw.rectangle((patch2_x, patch_y, patch2_x + zoomed_patch_width, patch_y + zoomed_patch_height), outline="black", width=2)
# --- 5. Save the Final Poster ---
canvas.save(output_path, quality=95)
print(f"\nSuccess! Comparison poster saved to: {output_path}")
except FileNotFoundError as e:
print(f"Error: An input file was not found. Details: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Create a side-by-side comparison poster from two images, featuring main views and a 3x3 grid of zoomed-in 'rule of thirds' patches.",
formatter_class=argparse.RawTextHelpFormatter,
epilog="Example:\n python create_comparison_poster.py image_A.jpg image_B.jpg comparison_result.png"
)
parser.add_argument("input1", help="Path to the first high-resolution input image (Image A).")
parser.add_argument("input2", help="Path to the second high-resolution input image (Image B).")
parser.add_argument("output", help="Path for the generated comparison poster image.")
if len(sys.argv) < 4:
parser.print_help(sys.stderr)
sys.exit(1)
args = parser.parse_args()
create_comparison_poster(args.input1, args.input2, args.output)

View File

@ -5,14 +5,27 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"colour-science>=0.4.6",
"imageio>=2.37.0",
"jupyter>=1.1.1",
"jupyterlab>=4.4.3",
"numpy>=2.2.6",
"pillow>=11.2.1",
"pyfftw>=0.15.0",
"rawpy>=0.25.0",
"scipy>=1.15.3",
"warp-lang>=1.7.2",
"colour-science>=0.4.6",
"imageio>=2.37.0",
"jupyter>=1.1.1",
"jupyterlab>=4.4.3",
"matplotlib>=3.10.3",
"numpy>=2.2.6",
"opencv-python>=4.11.0.86",
"pillow>=11.2.1",
"pyfftw>=0.15.0",
"rawpy>=0.25.0",
"scikit-image>=0.25.2",
"scipy>=1.15.3",
"torch>=2.7.1",
"warp-lang>=1.7.2",
]
[tool.uv.sources]
torch = [{ index = "pytorch-cu128" }]
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true

View File

@ -1,6 +1,6 @@
{
"info": {
"name": "Ektar 100",
"name": "Ektar",
"description": "KODAK PROFESSIONAL EKTAR 100 Film is the world's finest grain color negative film. With ISO 100 speed, high saturation and ultra-vivid color, this film offers the finest, smoothest grain of any color negative film available today. An ideal choice for commercial photographers and advanced amateurs, KODAK PROFESSIONAL EKTAR 100 Film is recommended for applications such as nature, travel and outdoor photography, as well as for fashion and product photography.",
"format_mm": 35,
"version": "1.0.0"

394
sim_data/gold_1000.json Normal file
View File

@ -0,0 +1,394 @@
{
"info": {
"name": "Gold",
"description": "KODAK ROYAL GOLD 1000 Film with its high sharpness and good grain is intended for low-light situations or subjects that require higher shutter speeds to stop action. It also allows you to use high shutter speeds for hand-holding telephoto lenses, or small apertures for increasing depth of field. Its improved sensitivity to tungsten light will provide pleasing results in situations where the lighting is difficult to meter. Although the film is balanced for exposure with daylight or electronic flash, you can also expose it with most existing light sources without filters.",
"format_mm": 35,
"version": "1.0.0"
},
"processing": {
"gamma": {
"r_factor": 1.0,
"g_factor": 1.0,
"b_factor": 1.0
},
"balance": {
"r_shift": 0.0,
"g_shift": 0.0,
"b_shift": 0.0
}
},
"properties": {
"calibration": {
"iso": 1000,
"middle_gray_logE": -1.14
},
"halation": {
"strength": {
"r": 0.028,
"g": 0.014,
"b": 0.004
},
"size_um": {
"r": 400.0,
"g": 200.0,
"b": 100.0
}
},
"couplers": {
"saturation_amount": 1.0,
"dir_amount_rgb": [0.7, 0.9, 0.5],
"dir_diffusion_um": 15.0,
"dir_diffusion_interlayer": 1.5
},
"interlayer": {
"diffusion_um": 3.33
},
"curves": {
"hd": [{"d":-3.86,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.82,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.77,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.73,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.68,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.64,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.59,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.55,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.51,"b":0.92,"g":0.77,"r":0.34},
{"d":-3.46,"b":0.92,"g":0.77,"r":0.35},
{"d":-3.41,"b":0.92,"g":0.77,"r":0.35},
{"d":-3.37,"b":0.93,"g":0.77,"r":0.35},
{"d":-3.33,"b":0.93,"g":0.78,"r":0.35},
{"d":-3.28,"b":0.94,"g":0.78,"r":0.36},
{"d":-3.24,"b":0.95,"g":0.78,"r":0.36},
{"d":-3.19,"b":0.96,"g":0.79,"r":0.37},
{"d":-3.15,"b":0.97,"g":0.8,"r":0.38},
{"d":-3.1,"b":0.99,"g":0.81,"r":0.39},
{"d":-3.06,"b":1.01,"g":0.82,"r":0.4},
{"d":-3.01,"b":1.02,"g":0.83,"r":0.41},
{"d":-2.97,"b":1.05,"g":0.85,"r":0.43},
{"d":-2.92,"b":1.07,"g":0.86,"r":0.44},
{"d":-2.88,"b":1.1,"g":0.88,"r":0.46},
{"d":-2.83,"b":1.13,"g":0.9,"r":0.48},
{"d":-2.79,"b":1.16,"g":0.92,"r":0.5},
{"d":-2.74,"b":1.19,"g":0.95,"r":0.52},
{"d":-2.7,"b":1.22,"g":0.97,"r":0.55},
{"d":-2.65,"b":1.25,"g":1,"r":0.57},
{"d":-2.61,"b":1.28,"g":1.02,"r":0.59},
{"d":-2.56,"b":1.32,"g":1.05,"r":0.61},
{"d":-2.52,"b":1.35,"g":1.08,"r":0.64},
{"d":-2.47,"b":1.38,"g":1.1,"r":0.66},
{"d":-2.43,"b":1.41,"g":1.13,"r":0.69},
{"d":-2.38,"b":1.45,"g":1.16,"r":0.71},
{"d":-2.34,"b":1.48,"g":1.18,"r":0.74},
{"d":-2.29,"b":1.51,"g":1.21,"r":0.77},
{"d":-2.25,"b":1.54,"g":1.24,"r":0.79},
{"d":-2.2,"b":1.57,"g":1.26,"r":0.82},
{"d":-2.16,"b":1.6,"g":1.29,"r":0.84},
{"d":-2.11,"b":1.64,"g":1.32,"r":0.87},
{"d":-2.07,"b":1.67,"g":1.35,"r":0.89},
{"d":-2.02,"b":1.7,"g":1.37,"r":0.92},
{"d":-1.98,"b":1.73,"g":1.4,"r":0.95},
{"d":-1.93,"b":1.77,"g":1.43,"r":0.97},
{"d":-1.89,"b":1.8,"g":1.46,"r":1},
{"d":-1.84,"b":1.83,"g":1.49,"r":1.03},
{"d":-1.8,"b":1.86,"g":1.51,"r":1.05},
{"d":-1.75,"b":1.9,"g":1.54,"r":1.08},
{"d":-1.71,"b":1.93,"g":1.57,"r":1.11},
{"d":-1.66,"b":1.96,"g":1.6,"r":1.13},
{"d":-1.62,"b":1.99,"g":1.63,"r":1.16},
{"d":-1.57,"b":2.03,"g":1.66,"r":1.19},
{"d":-1.53,"b":2.06,"g":1.68,"r":1.22},
{"d":-1.48,"b":2.09,"g":1.71,"r":1.24},
{"d":-1.44,"b":2.13,"g":1.74,"r":1.27},
{"d":-1.39,"b":2.16,"g":1.77,"r":1.3},
{"d":-1.35,"b":2.19,"g":1.8,"r":1.32},
{"d":-1.3,"b":2.23,"g":1.82,"r":1.35},
{"d":-1.26,"b":2.26,"g":1.85,"r":1.37},
{"d":-1.21,"b":2.29,"g":1.88,"r":1.4},
{"d":-1.17,"b":2.32,"g":1.91,"r":1.43},
{"d":-1.12,"b":2.36,"g":1.94,"r":1.45},
{"d":-1.08,"b":2.39,"g":1.96,"r":1.48},
{"d":-1.04,"b":2.42,"g":1.99,"r":1.5},
{"d":-0.99,"b":2.45,"g":2.02,"r":1.53},
{"d":-0.94,"b":2.48,"g":2.04,"r":1.55},
{"d":-0.9,"b":2.51,"g":2.07,"r":1.58},
{"d":-0.86,"b":2.54,"g":2.09,"r":1.61},
{"d":-0.81,"b":2.57,"g":2.11,"r":1.63},
{"d":-0.77,"b":2.6,"g":2.14,"r":1.65},
{"d":-0.72,"b":2.62,"g":2.16,"r":1.67},
{"d":-0.68,"b":2.65,"g":2.19,"r":1.7},
{"d":-0.63,"b":2.67,"g":2.21,"r":1.72},
{"d":-0.59,"b":2.7,"g":2.23,"r":1.74},
{"d":-0.54,"b":2.72,"g":2.26,"r":1.76},
{"d":-0.5,"b":2.75,"g":2.28,"r":1.78},
{"d":-0.45,"b":2.77,"g":2.3,"r":1.8},
{"d":-0.41,"b":2.79,"g":2.32,"r":1.81},
{"d":-0.36,"b":2.82,"g":2.34,"r":1.83},
{"d":-0.32,"b":2.84,"g":2.36,"r":1.85},
{"d":-0.27,"b":2.86,"g":2.38,"r":1.87},
{"d":-0.23,"b":2.88,"g":2.39,"r":1.88},
{"d":-0.18,"b":2.9,"g":2.41,"r":1.9},
{"d":-0.14,"b":2.92,"g":2.43,"r":1.92},
{"d":-0.09,"b":2.93,"g":2.44,"r":1.93}],
"spectral_sensitivity" : [
{"wavelength":382.8,"y":1.82,"m":0,"c":0},
{"wavelength":387.3,"y":1.88,"m":0,"c":0},
{"wavelength":390.1,"y":1.93,"m":0,"c":0},
{"wavelength":390.7,"y":1.99,"m":0,"c":0},
{"wavelength":393.8,"y":2.04,"m":0,"c":0},
{"wavelength":395.1,"y":2.12,"m":0,"c":0},
{"wavelength":396.6,"y":2.18,"m":0,"c":0},
{"wavelength":397.9,"y":2.18,"m":0,"c":0},
{"wavelength":398,"y":2.23,"m":0,"c":0},
{"wavelength":399.7,"y":2.28,"m":0,"c":0},
{"wavelength":400.1,"y":2.22,"m":0,"c":0},
{"wavelength":400.2,"y":2.26,"m":0,"c":0},
{"wavelength":400.2,"y":2.28,"m":0,"c":0},
{"wavelength":400.7,"y":2.35,"m":0,"c":0},
{"wavelength":403.5,"y":2.48,"m":0,"c":0},
{"wavelength":404.8,"y":2.54,"m":0,"c":0},
{"wavelength":406.2,"y":2.61,"m":0,"c":0},
{"wavelength":407.5,"y":2.69,"m":0,"c":0},
{"wavelength":408.6,"y":2.75,"m":0,"c":0},
{"wavelength":409.5,"y":2.81,"m":0,"c":0},
{"wavelength":410.5,"y":2.87,"m":0,"c":0},
{"wavelength":412.5,"y":2.93,"m":0,"c":0},
{"wavelength":416.2,"y":2.97,"m":0,"c":0},
{"wavelength":421,"y":2.99,"m":0,"c":0},
{"wavelength":426.4,"y":2.98,"m":0,"c":0},
{"wavelength":431.4,"y":2.97,"m":0,"c":0},
{"wavelength":436.5,"y":2.96,"m":0,"c":0},
{"wavelength":441.7,"y":2.94,"m":0,"c":0},
{"wavelength":446.9,"y":2.92,"m":0,"c":0},
{"wavelength":451.2,"y":2.91,"m":0,"c":0},
{"wavelength":457.1,"y":2.88,"m":0,"c":0},
{"wavelength":462.2,"y":2.86,"m":1.48,"c":0},
{"wavelength":467.6,"y":2.86,"m":1.5,"c":0},
{"wavelength":472.8,"y":2.85,"m":1.56,"c":0},
{"wavelength":477.3,"y":2.81,"m":1.62,"c":0},
{"wavelength":480.7,"y":2.76,"m":1.73,"c":0},
{"wavelength":483.1,"y":2.71,"m":1.79,"c":0},
{"wavelength":485.4,"y":2.65,"m":1.85,"c":0},
{"wavelength":487.7,"y":2.6,"m":1.91,"c":0},
{"wavelength":490,"y":2.54,"m":2.08,"c":0},
{"wavelength":492.3,"y":2.49,"m":2.15,"c":0},
{"wavelength":494.7,"y":2.43,"m":2.2,"c":0},
{"wavelength":500.4,"y":2.33,"m":2.26,"c":0},
{"wavelength":500.6,"y":2.26,"m":2.31,"c":0},
{"wavelength":503.4,"y":2.22,"m":2.31,"c":0},
{"wavelength":504.4,"y":2.17,"m":2.31,"c":0},
{"wavelength":505.7,"y":2.12,"m":2.36,"c":0},
{"wavelength":506.8,"y":2.07,"m":2.36,"c":0},
{"wavelength":507.8,"y":2.01,"m":2.41,"c":0},
{"wavelength":510.3,"y":1.94,"m":2.41,"c":0},
{"wavelength":511.7,"y":1.87,"m":2.41,"c":0},
{"wavelength":512.9,"y":1.82,"m":2.41,"c":0},
{"wavelength":514.1,"y":1.76,"m":2.47,"c":0},
{"wavelength":515.4,"y":1.71,"m":2.47,"c":0},
{"wavelength":516.6,"y":1.65,"m":2.47,"c":0},
{"wavelength":517.8,"y":1.6,"m":2.47,"c":0},
{"wavelength":519,"y":1.54,"m":2.47,"c":0},
{"wavelength":520.2,"y":1.49,"m":2.52,"c":0},
{"wavelength":521.4,"y":1.43,"m":2.52,"c":0},
{"wavelength":522.5,"y":1.38,"m":2.52,"c":0},
{"wavelength":523.6,"y":1.32,"m":2.52,"c":0},
{"wavelength":524.5,"y":1.27,"m":2.52,"c":0},
{"wavelength":525.4,"y":1.21,"m":2.58,"c":0},
{"wavelength":526.3,"y":1.16,"m":2.58,"c":0},
{"wavelength":527.1,"y":1.11,"m":2.58,"c":0},
{"wavelength":527.7,"y":1.07,"m":2.58,"c":0},
{"wavelength":528.7,"y":1,"m":2.58,"c":0},
{"wavelength":529.7,"y":0.92,"m":2.64,"c":0},
{"wavelength":530.5,"y":0.87,"m":2.64,"c":0},
{"wavelength":535.2,"y":0,"m":2.7,"c":0},
{"wavelength":540.1,"y":0,"m":2.74,"c":0},
{"wavelength":545.1,"y":0,"m":2.76,"c":0},
{"wavelength":550.7,"y":0,"m":2.77,"c":1.39},
{"wavelength":553.8,"y":0,"m":2.77,"c":1.45},
{"wavelength":555.7,"y":0,"m":2.74,"c":1.51},
{"wavelength":557.6,"y":0,"m":2.74,"c":1.56},
{"wavelength":559.9,"y":0,"m":2.7,"c":1.62},
{"wavelength":560.5,"y":0,"m":2.7,"c":1.69},
{"wavelength":565.3,"y":0,"m":2.67,"c":1.74},
{"wavelength":567.2,"y":0,"m":2.63,"c":1.8},
{"wavelength":569.8,"y":0,"m":2.63,"c":1.86},
{"wavelength":572.6,"y":0,"m":2.58,"c":1.91},
{"wavelength":574.6,"y":0,"m":2.52,"c":1.97},
{"wavelength":576.1,"y":0,"m":2.47,"c":2.02},
{"wavelength":577.3,"y":0,"m":2.41,"c":2.09},
{"wavelength":578.5,"y":0,"m":2.36,"c":2.09},
{"wavelength":579.5,"y":0,"m":2.3,"c":2.2},
{"wavelength":580.9,"y":0,"m":2.17,"c":2.2},
{"wavelength":581.1,"y":0,"m":2.22,"c":2.25},
{"wavelength":582.5,"y":0,"m":2.09,"c":2.25},
{"wavelength":583.1,"y":0,"m":2.01,"c":2.25},
{"wavelength":584.4,"y":0,"m":1.93,"c":2.25},
{"wavelength":585.1,"y":0,"m":1.87,"c":2.32},
{"wavelength":585.7,"y":0,"m":1.82,"c":2.32},
{"wavelength":586.3,"y":0,"m":1.76,"c":2.32},
{"wavelength":586.9,"y":0,"m":1.71,"c":2.32},
{"wavelength":588.2,"y":0,"m":1.6,"c":2.32},
{"wavelength":588.9,"y":0,"m":1.54,"c":2.37},
{"wavelength":589.6,"y":0,"m":1.49,"c":2.37},
{"wavelength":590.8,"y":0,"m":1.38,"c":2.37},
{"wavelength":592.2,"y":0,"m":1.27,"c":2.37},
{"wavelength":592.9,"y":0,"m":1.22,"c":2.37},
{"wavelength":594.4,"y":0,"m":1.11,"c":2.41},
{"wavelength":595,"y":0,"m":1.07,"c":2.41},
{"wavelength":596,"y":0,"m":1,"c":2.41},
{"wavelength":597.2,"y":0,"m":0.92,"c":2.41},
{"wavelength":598,"y":0,"m":0.87,"c":2.41},
{"wavelength":598.8,"y":0,"m":0.81,"c":2.44},
{"wavelength":601.3,"y":0,"m":0.62,"c":2.44},
{"wavelength":606.1,"y":0,"m":0,"c":2.49},
{"wavelength":611.4,"y":0,"m":0,"c":2.54},
{"wavelength":616.6,"y":0,"m":0,"c":2.57},
{"wavelength":621.8,"y":0,"m":0,"c":2.61},
{"wavelength":626.3,"y":0,"m":0,"c":2.67},
{"wavelength":630.2,"y":0,"m":0,"c":2.73},
{"wavelength":633.7,"y":0,"m":0,"c":2.79},
{"wavelength":636.1,"y":0,"m":0,"c":2.84},
{"wavelength":638.8,"y":0,"m":0,"c":2.9},
{"wavelength":643.4,"y":0,"m":0,"c":2.93},
{"wavelength":648.3,"y":0,"m":0,"c":2.94},
{"wavelength":653.1,"y":0,"m":0,"c":2.95},
{"wavelength":658.5,"y":0,"m":0,"c":2.97},
{"wavelength":663.2,"y":0,"m":0,"c":2.96},
{"wavelength":664.5,"y":0,"m":0,"c":2.9},
{"wavelength":665.3,"y":0,"m":0,"c":2.85},
{"wavelength":666.2,"y":0,"m":0,"c":2.79},
{"wavelength":667,"y":0,"m":0,"c":2.74},
{"wavelength":667.8,"y":0,"m":0,"c":2.68},
{"wavelength":668.6,"y":0,"m":0,"c":2.63},
{"wavelength":669.2,"y":0,"m":0,"c":2.57},
{"wavelength":669.8,"y":0,"m":0,"c":2.52},
{"wavelength":670.6,"y":0,"m":0,"c":2.46},
{"wavelength":671.2,"y":0,"m":0,"c":2.41},
{"wavelength":671.8,"y":0,"m":0,"c":2.35},
{"wavelength":672.4,"y":0,"m":0,"c":2.3},
{"wavelength":673,"y":0,"m":0,"c":2.24},
{"wavelength":673.5,"y":0,"m":0,"c":2.19},
{"wavelength":674,"y":0,"m":0,"c":2.14},
{"wavelength":674.6,"y":0,"m":0,"c":2.08},
{"wavelength":675.5,"y":0,"m":0,"c":2.01},
{"wavelength":676.3,"y":0,"m":0,"c":1.93},
{"wavelength":676.8,"y":0,"m":0,"c":1.87},
{"wavelength":677.3,"y":0,"m":0,"c":1.82},
{"wavelength":677.9,"y":0,"m":0,"c":1.76},
{"wavelength":678.5,"y":0,"m":0,"c":1.71},
{"wavelength":679.1,"y":0,"m":0,"c":1.65},
{"wavelength":679.6,"y":0,"m":0,"c":1.6},
{"wavelength":680.1,"y":0,"m":0,"c":1.54},
{"wavelength":680.7,"y":0,"m":0,"c":1.49},
{"wavelength":681.3,"y":0,"m":0,"c":1.43},
{"wavelength":681.9,"y":0,"m":0,"c":1.38},
{"wavelength":682.5,"y":0,"m":0,"c":1.32},
{"wavelength":683.2,"y":0,"m":0,"c":1.27},
{"wavelength":683.8,"y":0,"m":0,"c":1.22},
{"wavelength":684.6,"y":0,"m":0,"c":1.16},
{"wavelength":685.3,"y":0,"m":0,"c":1.1},
{"wavelength":685.9,"y":0,"m":0,"c":1.07},
{"wavelength":688,"y":0,"m":0,"c":0.95},
{"wavelength":688.7,"y":0,"m":0,"c":0.87},
{"wavelength":689.4,"y":0,"m":0,"c":0.81},
{"wavelength":690.2,"y":0,"m":0,"c":0.76},
{"wavelength":690.9,"y":0,"m":0,"c":0.7},
{"wavelength":691.5,"y":0,"m":0,"c":0.67}
],
"spectral_dye_absorption": [
{"wavelength":400.25,"y":0.5645,"m":0,"c":0.0003,"dmin":0.6831882116543871},
{"wavelength":403.52,"y":0.6071,"m":0,"c":0.0004,"dmin":0.6764668453},
{"wavelength":406.78,"y":0.6497,"m":0,"c":0.0004,"dmin":0.6714668452779639},
{"wavelength":410.05,"y":0.6919,"m":0,"c":0.0005,"dmin":0.6764668453},
{"wavelength":413.32,"y":0.7332,"m":0,"c":0.0006,"dmin":0.6831882116543871},
{"wavelength":416.58,"y":0.773,"m":0,"c":0.0007,"dmin":0.7200267916945747},
{"wavelength":419.85,"y":0.8111,"m":0,"c":0.0008,"dmin":0.7200267916945748},
{"wavelength":423.12,"y":0.8468,"m":0,"c":0.001,"dmin":0.7618888144675151},
{"wavelength":426.38,"y":0.8796,"m":0.0001,"c":0.0012,"dmin":0.797052913596784},
{"wavelength":429.65,"y":0.9093,"m":0.0001,"c":0.0014,"dmin":0.797052913596785},
{"wavelength":432.91,"y":0.9353,"m":0.0002,"c":0.0016,"dmin":0.797052913596786},
{"wavelength":436.18,"y":0.9573,"m":0.0003,"c":0.0019,"dmin":0.8171466845277964},
{"wavelength":439.45,"y":0.975,"m":0.0005,"c":0.0022,"dmin":0.8171466845277965},
{"wavelength":442.71,"y":0.9881,"m":0.0008,"c":0.0025,"dmin":0.8204956463496315},
{"wavelength":445.98,"y":0.9965,"m":0.0013,"c":0.0029,"dmin":0.8204956463496316},
{"wavelength":449.25,"y":0.9999,"m":0.002,"c":0.0034,"dmin":0.8204956463496317},
{"wavelength":452.51,"y":0.9984,"m":0.003,"c":0.0039,"dmin":0.8121232417950435},
{"wavelength":455.78,"y":0.992,"m":0.0045,"c":0.0045,"dmin":0.8121232417950436},
{"wavelength":459.05,"y":0.9807,"m":0.0067,"c":0.0052,"dmin":0.8121232417950437},
{"wavelength":462.31,"y":0.9648,"m":0.0098,"c":0.006,"dmin":0.7937039517749497},
{"wavelength":465.58,"y":0.9445,"m":0.0141,"c":0.0069,"dmin":0.7937039517749498},
{"wavelength":468.84,"y":0.92,"m":0.0201,"c":0.0079,"dmin":0.7937039517749499},
{"wavelength":472.11,"y":0.8917,"m":0.028,"c":0.009,"dmin":0.775284661754855},
{"wavelength":475.38,"y":0.86,"m":0.0386,"c":0.0103,"dmin":0.775284661754856},
{"wavelength":478.64,"y":0.8254,"m":0.0523,"c":0.0117,"dmin":0.775284661754857},
{"wavelength":481.91,"y":0.7882,"m":0.0698,"c":0.0133,"dmin":0.7585398526456797},
{"wavelength":485.18,"y":0.749,"m":0.0919,"c":0.0151,"dmin":0.7585398526456798},
{"wavelength":488.44,"y":0.7082,"m":0.1191,"c":0.0171,"dmin":0.7585398526456799},
{"wavelength":491.71,"y":0.6664,"m":0.1521,"c":0.0193,"dmin":0.7434695244474213},
{"wavelength":494.97,"y":0.6239,"m":0.1914,"c":0.0218,"dmin":0.7434695244474214},
{"wavelength":498.24,"y":0.5812,"m":0.2373,"c":0.0245,"dmin":0.7518419290020094},
{"wavelength":501.51,"y":0.5388,"m":0.2898,"c":0.0275,"dmin":0.7518419290020095},
{"wavelength":504.77,"y":0.497,"m":0.3486,"c":0.0309,"dmin":0.7719356999330208},
{"wavelength":508.04,"y":0.4562,"m":0.4132,"c":0.0346,"dmin":0.7937039517749498},
{"wavelength":511.31,"y":0.4167,"m":0.4825,"c":0.0387,"dmin":0.7937039517749499},
{"wavelength":514.57,"y":0.3787,"m":0.555,"c":0.0431,"dmin":0.7585398526456797},
{"wavelength":517.84,"y":0.3424,"m":0.629,"c":0.048,"dmin":0.7585398526456798},
{"wavelength":521.11,"y":0.3082,"m":0.7023,"c":0.0534,"dmin":0.7232350971},
{"wavelength":524.37,"y":0.2759,"m":0.7725,"c":0.0592,"dmin":0.6932350971198928},
{"wavelength":527.64,"y":0.2459,"m":0.8371,"c":0.0655,"dmin":0.693235097119893},
{"wavelength":530.9,"y":0.218,"m":0.8937,"c":0.0724,"dmin":0.644675150703281},
{"wavelength":534.17,"y":0.1923,"m":0.94,"c":0.0799,"dmin":0.644675150703282},
{"wavelength":537.44,"y":0.1688,"m":0.974,"c":0.088,"dmin":0.644675150703283},
{"wavelength":540.7,"y":0.1475,"m":0.9942,"c":0.0967,"dmin":0.6162089752176824},
{"wavelength":543.97,"y":0.1282,"m":0.9999,"c":0.106,"dmin":0.6162089752176825},
{"wavelength":547.24,"y":0.1109,"m":0.9907,"c":0.1161,"dmin":0.6162089752176826},
{"wavelength":550.5,"y":0.0954,"m":0.967,"c":0.1269,"dmin":0.6028131279303415},
{"wavelength":553.77,"y":0.0817,"m":0.9299,"c":0.1384,"dmin":0.6028131279303416},
{"wavelength":557.04,"y":0.0696,"m":0.8809,"c":0.1507,"dmin":0.6028131279303417},
{"wavelength":560.3,"y":0.059,"m":0.8222,"c":0.1637,"dmin":0.5726724715338245},
{"wavelength":563.57,"y":0.0498,"m":0.756,"c":0.1776,"dmin":0.5726724715338246},
{"wavelength":566.83,"y":0.0418,"m":0.6848,"c":0.1923,"dmin":0.5241125251172135},
{"wavelength":570.1,"y":0.0349,"m":0.6112,"c":0.2078,"dmin":0.5241125251172136},
{"wavelength":573.37,"y":0.0291,"m":0.5373,"c":0.2241,"dmin":0.4621567314132618},
{"wavelength":576.63,"y":0.024,"m":0.4654,"c":0.2413,"dmin":0.4621567314132619},
{"wavelength":579.9,"y":0.0198,"m":0.3972,"c":0.2593,"dmin":0.37340924313462825},
{"wavelength":583.17,"y":0.0162,"m":0.3339,"c":0.2781,"dmin":0.37340924313462825},
{"wavelength":586.43,"y":0.0132,"m":0.2765,"c":0.2977,"dmin":0.3534092431},
{"wavelength":589.7,"y":0.0107,"m":0.2256,"c":0.3182,"dmin":0.2997320830542531},
{"wavelength":592.96,"y":0.0086,"m":0.1814,"c":0.3393,"dmin":0.2997320830542532},
{"wavelength":596.23,"y":0.0069,"m":0.1436,"c":0.3613,"dmin":0.2997320830542533},
{"wavelength":599.5,"y":0.0055,"m":0.1121,"c":0.3839,"dmin":0.24614869390488947},
{"wavelength":602.76,"y":0.0044,"m":0.0861,"c":0.4072,"dmin":0.24614869390488947},
{"wavelength":606.03,"y":0.0035,"m":0.0652,"c":0.431,"dmin":0.2461486939048895},
{"wavelength":609.3,"y":0.0027,"m":0.0487,"c":0.4555,"dmin":0.20261219022103147},
{"wavelength":612.56,"y":0.0022,"m":0.0358,"c":0.4804,"dmin":0.20261219022103147},
{"wavelength":615.83,"y":0.0017,"m":0.0259,"c":0.5057,"dmin":0.20261219022103147},
{"wavelength":619.1,"y":0.0013,"m":0.0185,"c":0.5314,"dmin":0.19256530475552575},
{"wavelength":622.36,"y":0.001,"m":0.013,"c":0.5573,"dmin":0.19256530475552577},
{"wavelength":625.63,"y":0.0008,"m":0.009,"c":0.5835,"dmin":0.19256530475552577},
{"wavelength":628.89,"y":0.0006,"m":0.0061,"c":0.6097,"dmin":0.19256530475552577},
{"wavelength":632.16,"y":0.0004,"m":0.0041,"c":0.6359,"dmin":0.1925653047555258},
{"wavelength":635.43,"y":0.0003,"m":0.0027,"c":0.6619,"dmin":0.1925653047555258},
{"wavelength":638.69,"y":0.0003,"m":0.0018,"c":0.6878,"dmin":0.19256530475552575},
{"wavelength":641.96,"y":0.0002,"m":0.0011,"c":0.7133,"dmin":0.19256530475552577},
{"wavelength":645.23,"y":0.0001,"m":0.0007,"c":0.7384,"dmin":0.19256530475552577},
{"wavelength":648.49,"y":0.0001,"m":0.0005,"c":0.763,"dmin":0.19256530475552577},
{"wavelength":651.76,"y":0.0001,"m":0.0003,"c":0.7869,"dmin":0.20261219022103144},
{"wavelength":655.03,"y":0.0001,"m":0.0002,"c":0.81,"dmin":0.20261219022103147},
{"wavelength":658.29,"y":0,"m":0.0001,"c":0.8323,"dmin":0.20261219022103147},
{"wavelength":661.56,"y":0,"m":0.0001,"c":0.8536,"dmin":0.20261219022103147},
{"wavelength":664.82,"y":0,"m":0,"c":0.8738,"dmin":0.21098459477561954},
{"wavelength":668.09,"y":0,"m":0,"c":0.8928,"dmin":0.21098459477561957},
{"wavelength":671.36,"y":0,"m":0,"c":0.9105,"dmin":0.21098459477561957},
{"wavelength":674.62,"y":0,"m":0,"c":0.9268,"dmin":0.2109845947756196},
{"wavelength":677.89,"y":0,"m":0,"c":0.9417,"dmin":0.2109845947756196},
{"wavelength":681.16,"y":0,"m":0,"c":0.955,"dmin":0.21935699933020764},
{"wavelength":684.42,"y":0,"m":0,"c":0.9667,"dmin":0.21935699933020764},
{"wavelength":687.69,"y":0,"m":0,"c":0.9767,"dmin":0.21935699933020766},
{"wavelength":690.95,"y":0,"m":0,"c":0.985,"dmin":0.21935699933020766},
{"wavelength":694.22,"y":0,"m":0,"c":0.9915,"dmin":0.2260549229738781},
{"wavelength":697.49,"y":0,"m":0,"c":0.9962,"dmin":0.2260549229738782},
{"wavelength":700.75,"y":0,"m":0,"c":0.999,"dmin":0.22103148024112526}
]
}
}
}

View File

@ -1,6 +1,6 @@
{
"info": {
"name": "Portra 400",
"name": "Portra",
"description": "KODAK PROFESSIONAL PORTRA 400 is the world's finest grain high-speed color negative film. At true ISO 400 speed, this film delivers spectacular skin tones plus exceptional color saturation over a wide range of lighting conditions. PORTRA 400 Film is the ideal choice for portrait and fashion photography, as well as for nature, travel and outdoor photography, where the action is fast or the lighting can't be controlled.",
"format_mm": 35,
"version": "1.0.0"

194
testbench.py Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
import os
import sys
import argparse
import itertools
import subprocess
from multiprocessing import Pool
from functools import partial
# --- Configuration ---
# This dictionary maps the desired abbreviation to the full command-line flag.
# This makes it easy to add or remove flags in the future.
ARGS_MAP = {
# 'fd': '--force-d65',
# 'pnc': '--perform-negative-correction',
'pwb': '--perform-white-balance',
'pec': '--perform-exposure-correction',
# 'rae': '--raw-auto-exposure',
'sg': '--simulate-grain',
# 'mg': '--mono-grain'
}
# --- Worker Function for Multiprocessing ---
def run_filmcolor_command(job_info, filmcolor_path):
"""
Executes a single filmcolor command.
This function is designed to be called by a multiprocessing Pool.
"""
input_file, datasheet, output_file, flags = job_info
command = [
filmcolor_path,
input_file,
datasheet,
output_file
]
command.extend(flags)
command_str = " ".join(command)
print(f"🚀 Starting job: {os.path.basename(output_file)}")
try:
# Using subprocess.run to execute the command
# capture_output=True keeps stdout/stderr from cluttering the main display
# text=True decodes stdout/stderr as text
# check=True will raise a CalledProcessError if the command returns a non-zero exit code
result = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
encoding='utf-8'
)
return f"✅ SUCCESS: Created {output_file}"
except FileNotFoundError:
return f"❌ ERROR: filmcolor executable not found at '{filmcolor_path}'"
except subprocess.CalledProcessError as e:
# This block runs if the command fails (returns non-zero exit code)
error_message = (
f"❌ FAILURE: Could not process {os.path.basename(input_file)} with {os.path.basename(datasheet)}\n"
f" Command: {command_str}\n"
f" Exit Code: {e.returncode}\n"
f" Stderr: {e.stderr.strip()}"
)
return error_message
except Exception as e:
return f"❌ UNEXPECTED ERROR: {e}"
# --- Main Script Logic ---
def main():
parser = argparse.ArgumentParser(
description="A testbench runner for the 'filmcolor' script.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"input_dir",
help="The root directory containing subfolders with RAW images (ARW, DNG)."
)
parser.add_argument(
"datasheet_dir",
help="The directory containing the film datasheet JSON files."
)
parser.add_argument(
"filmcolor_path",
help="The path to the 'filmcolor' executable script."
)
parser.add_argument(
"-j", "--jobs",
type=int,
default=3,
help="Number of parallel jobs to run. (Default: 3)"
)
args = parser.parse_args()
# 1. Find all input RAW files
raw_files = []
print(f"🔎 Scanning for RAW files in '{args.input_dir}'...")
for root, _, files in os.walk(args.input_dir):
for file in files:
if file.lower().endswith(('.arw', '.dng')):
raw_files.append(os.path.join(root, file))
if not raw_files:
print("❌ No RAW (.arW or .DNG) files found. Exiting.")
sys.exit(1)
print(f" Found {len(raw_files)} RAW files.")
# 2. Find all datasheet JSON files
datasheet_files = []
print(f"🔎 Scanning for JSON files in '{args.datasheet_dir}'...")
try:
for file in os.listdir(args.datasheet_dir):
if file.lower().endswith('.json'):
datasheet_files.append(os.path.join(args.datasheet_dir, file))
except FileNotFoundError:
print(f"❌ Datasheet directory not found at '{args.datasheet_dir}'. Exiting.")
sys.exit(1)
if not datasheet_files:
print("❌ No datasheet (.json) files found. Exiting.")
sys.exit(1)
print(f" Found {len(datasheet_files)} datasheet files.")
# 3. Generate all argument combinations
arg_abbreviations = list(ARGS_MAP.keys())
all_arg_combos = []
# Loop from 0 to len(abbreviations) to get combinations of all lengths
for i in range(len(arg_abbreviations) + 1):
for combo in itertools.combinations(arg_abbreviations, i):
all_arg_combos.append(sorted(list(combo))) # Sort for consistent naming
# 4. Create the full list of jobs to run
jobs_to_run = []
for raw_file_path in raw_files:
input_dir = os.path.dirname(raw_file_path)
input_filename = os.path.basename(raw_file_path)
for datasheet_path in datasheet_files:
datasheet_name = os.path.splitext(os.path.basename(datasheet_path))[0]
for arg_combo_abbrs in all_arg_combos:
# Build the output filename
arg_suffix = "-".join(arg_combo_abbrs)
# Handle the case with no arguments to avoid a trailing hyphen
if arg_suffix:
output_name = f"{input_filename}-{datasheet_name}-{arg_suffix}.jpg"
else:
output_name = f"{input_filename}-{datasheet_name}.jpg"
output_path = os.path.join(input_dir, output_name)
# Get the full flags from the abbreviations
flags = [ARGS_MAP[abbr] for abbr in arg_combo_abbrs] + ['--perform-negative-correction'] # always include this flag
# Add the complete job description to our list
jobs_to_run.append((raw_file_path, datasheet_path, output_path, flags))
total_jobs = len(jobs_to_run)
print(f"\n✨ Generated {total_jobs} total jobs to run.")
if total_jobs == 0:
print("Nothing to do. Exiting.")
sys.exit(0)
# Ask for confirmation before starting a large number of jobs
try:
confirm = input(f"Proceed with running {total_jobs} jobs using {args.jobs} parallel processes? (y/N): ")
if confirm.lower() != 'y':
print("Aborted by user.")
sys.exit(0)
except KeyboardInterrupt:
print("\nAborted by user.")
sys.exit(0)
# 5. Run the jobs in a multiprocessing pool
print("\n--- Starting Testbench ---\n")
# `partial` is used to "pre-fill" the filmcolor_path argument of our worker function
worker_func = partial(run_filmcolor_command, filmcolor_path=args.filmcolor_path)
with Pool(processes=args.jobs) as pool:
# imap_unordered is great for this: it yields results as they complete,
# providing real-time feedback without waiting for all jobs to finish.
for i, result in enumerate(pool.imap_unordered(worker_func, jobs_to_run), 1):
print(f"[{i}/{total_jobs}] {result}")
print("\n--- Testbench Finished ---")
if __name__ == "__main__":
main()

441
uv.lock generated
View File

@ -1,6 +1,12 @@
version = 1
revision = 1
requires-python = ">=3.13"
resolution-markers = [
"sys_platform == 'darwin'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'",
"sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
]
[[package]]
name = "anyio"
@ -225,6 +231,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 },
]
[[package]]
name = "contourpy"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630 },
{ url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670 },
{ url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694 },
{ url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986 },
{ url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060 },
{ url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747 },
{ url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895 },
{ url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098 },
{ url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535 },
{ url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096 },
{ url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090 },
{ url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643 },
{ url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443 },
{ url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865 },
{ url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162 },
{ url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355 },
{ url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935 },
{ url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168 },
{ url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550 },
{ url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214 },
]
[[package]]
name = "cycler"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 },
]
[[package]]
name = "debugpy"
version = "1.8.14"
@ -274,6 +320,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924 },
]
[[package]]
name = "filelock"
version = "3.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 },
]
[[package]]
name = "filmsim"
version = "0.1.0"
@ -283,11 +338,15 @@ dependencies = [
{ name = "imageio" },
{ name = "jupyter" },
{ name = "jupyterlab" },
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "pyfftw" },
{ name = "rawpy" },
{ name = "scikit-image" },
{ name = "scipy" },
{ name = "torch" },
{ name = "warp-lang" },
]
@ -297,14 +356,35 @@ requires-dist = [
{ name = "imageio", specifier = ">=2.37.0" },
{ name = "jupyter", specifier = ">=1.1.1" },
{ name = "jupyterlab", specifier = ">=4.4.3" },
{ name = "matplotlib", specifier = ">=3.10.3" },
{ name = "numpy", specifier = ">=2.2.6" },
{ name = "opencv-python", specifier = ">=4.11.0.86" },
{ name = "pillow", specifier = ">=11.2.1" },
{ name = "pyfftw", specifier = ">=0.15.0" },
{ name = "rawpy", specifier = ">=0.25.0" },
{ name = "scikit-image", specifier = ">=0.25.2" },
{ name = "scipy", specifier = ">=1.15.3" },
{ name = "torch", specifier = ">=2.7.1", index = "https://download.pytorch.org/whl/cu128" },
{ name = "warp-lang", specifier = ">=1.7.2" },
]
[[package]]
name = "fonttools"
version = "4.58.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/a9/3319c6ae07fd9dde51064ddc6d82a2b707efad8ed407d700a01091121bbc/fonttools-4.58.2.tar.gz", hash = "sha256:4b491ddbfd50b856e84b0648b5f7941af918f6d32f938f18e62b58426a8d50e2", size = 3524285 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/01/29f81970a508408af20b434ff5136cd1c7ef92198957eb8ddadfbb9ef177/fonttools-4.58.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:829048ef29dbefec35d95cc6811014720371c95bdc6ceb0afd2f8e407c41697c", size = 2732398 },
{ url = "https://files.pythonhosted.org/packages/0c/f1/095f2338359333adb2f1c51b8b2ad94bf9a2fa17e5fcbdf8a7b8e3672d2d/fonttools-4.58.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:64998c5993431e45b474ed5f579f18555f45309dd1cf8008b594d2fe0a94be59", size = 2306390 },
{ url = "https://files.pythonhosted.org/packages/bf/d4/9eba134c7666a26668c28945355cd86e5d57828b6b8d952a5489fe45d7e2/fonttools-4.58.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b887a1cf9fbcb920980460ee4a489c8aba7e81341f6cdaeefa08c0ab6529591c", size = 4795100 },
{ url = "https://files.pythonhosted.org/packages/2a/34/345f153a24c1340daa62340c3be2d1e5ee6c1ee57e13f6d15613209e688b/fonttools-4.58.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27d74b9f6970cefbcda33609a3bee1618e5e57176c8b972134c4e22461b9c791", size = 4864585 },
{ url = "https://files.pythonhosted.org/packages/01/5f/091979a25c9a6c4ba064716cfdfe9431f78ed6ffba4bd05ae01eee3532e9/fonttools-4.58.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec26784610056a770e15a60f9920cee26ae10d44d1e43271ea652dadf4e7a236", size = 4866191 },
{ url = "https://files.pythonhosted.org/packages/9d/09/3944d0ece4a39560918cba37c2e0453a5f826b665a6db0b43abbd9dbe7e1/fonttools-4.58.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed0a71d57dd427c0fb89febd08cac9b925284d2a8888e982a6c04714b82698d7", size = 5003867 },
{ url = "https://files.pythonhosted.org/packages/68/97/190b8f9ba22f8b7d07df2faa9fd7087b453776d0705d3cb5b0cbd89b8ef0/fonttools-4.58.2-cp313-cp313-win32.whl", hash = "sha256:994e362b01460aa863ef0cb41a29880bc1a498c546952df465deff7abf75587a", size = 2175688 },
{ url = "https://files.pythonhosted.org/packages/94/ea/0e6d4a39528dbb6e0f908c2ad219975be0a506ed440fddf5453b90f76981/fonttools-4.58.2-cp313-cp313-win_amd64.whl", hash = "sha256:f95dec862d7c395f2d4efe0535d9bdaf1e3811e51b86432fa2a77e73f8195756", size = 2226464 },
{ url = "https://files.pythonhosted.org/packages/e8/e5/c1cb8ebabb80be76d4d28995da9416816653f8f572920ab5e3d2e3ac8285/fonttools-4.58.2-py3-none-any.whl", hash = "sha256:84f4b0bcfa046254a65ee7117094b4907e22dc98097a220ef108030eb3c15596", size = 1114597 },
]
[[package]]
name = "fqdn"
version = "1.5.1"
@ -314,6 +394,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 },
]
[[package]]
name = "fsspec"
version = "2025.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/f7/27f15d41f0ed38e8fcc488584b57e902b331da7f7c6dcda53721b15838fc/fsspec-2025.5.1.tar.gz", hash = "sha256:2e55e47a540b91843b755e83ded97c6e897fa0942b11490113f09e9c443c2475", size = 303033 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/61/78c7b3851add1481b048b5fdc29067397a1784e2910592bc81bb3f608635/fsspec-2025.5.1-py3-none-any.whl", hash = "sha256:24d3a2e663d5fc735ab256263c4075f374a174c3410c0b25e5bd1970bceaa462", size = 199052 },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -653,7 +742,7 @@ dependencies = [
{ name = "overrides" },
{ name = "packaging" },
{ name = "prometheus-client" },
{ name = "pywinpty", marker = "os_name == 'nt'" },
{ name = "pywinpty", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "pyzmq" },
{ name = "send2trash" },
{ name = "terminado" },
@ -671,7 +760,7 @@ name = "jupyter-server-terminals"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pywinpty", marker = "os_name == 'nt'" },
{ name = "pywinpty", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "terminado" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430 }
@ -739,6 +828,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/6a/ca128561b22b60bd5a0c4ea26649e68c8556b82bc70a0c396eebc977fe86/jupyterlab_widgets-3.0.15-py3-none-any.whl", hash = "sha256:d59023d7d7ef71400d51e6fee9a88867f6e65e10a4201605d2d7f3e8f012a31c", size = 216571 },
]
[[package]]
name = "kiwisolver"
version = "1.4.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156 },
{ url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555 },
{ url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071 },
{ url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053 },
{ url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278 },
{ url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139 },
{ url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517 },
{ url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952 },
{ url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132 },
{ url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997 },
{ url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060 },
{ url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471 },
{ url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793 },
{ url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855 },
{ url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430 },
{ url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294 },
{ url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736 },
{ url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194 },
{ url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942 },
{ url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341 },
{ url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455 },
{ url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138 },
{ url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857 },
{ url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129 },
{ url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538 },
{ url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661 },
{ url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710 },
{ url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213 },
]
[[package]]
name = "lazy-loader"
version = "0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
@ -767,6 +904,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "matplotlib"
version = "3.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy" },
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/91/d49359a21893183ed2a5b6c76bec40e0b1dcbf8ca148f864d134897cfc75/matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0", size = 34799811 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/c1/23cfb566a74c696a3b338d8955c549900d18fe2b898b6e94d682ca21e7c2/matplotlib-3.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f2efccc8dcf2b86fc4ee849eea5dcaecedd0773b30f47980dc0cbeabf26ec84", size = 8180318 },
{ url = "https://files.pythonhosted.org/packages/6c/0c/02f1c3b66b30da9ee343c343acbb6251bef5b01d34fad732446eaadcd108/matplotlib-3.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ddbba06a6c126e3301c3d272a99dcbe7f6c24c14024e80307ff03791a5f294e", size = 8051132 },
{ url = "https://files.pythonhosted.org/packages/b4/ab/8db1a5ac9b3a7352fb914133001dae889f9fcecb3146541be46bed41339c/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748302b33ae9326995b238f606e9ed840bf5886ebafcb233775d946aa8107a15", size = 8457633 },
{ url = "https://files.pythonhosted.org/packages/f5/64/41c4367bcaecbc03ef0d2a3ecee58a7065d0a36ae1aa817fe573a2da66d4/matplotlib-3.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80fcccbef63302c0efd78042ea3c2436104c5b1a4d3ae20f864593696364ac7", size = 8601031 },
{ url = "https://files.pythonhosted.org/packages/12/6f/6cc79e9e5ab89d13ed64da28898e40fe5b105a9ab9c98f83abd24e46d7d7/matplotlib-3.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55e46cbfe1f8586adb34f7587c3e4f7dedc59d5226719faf6cb54fc24f2fd52d", size = 9406988 },
{ url = "https://files.pythonhosted.org/packages/b1/0f/eed564407bd4d935ffabf561ed31099ed609e19287409a27b6d336848653/matplotlib-3.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:151d89cb8d33cb23345cd12490c76fd5d18a56581a16d950b48c6ff19bb2ab93", size = 8068034 },
{ url = "https://files.pythonhosted.org/packages/3e/e5/2f14791ff69b12b09e9975e1d116d9578ac684460860ce542c2588cb7a1c/matplotlib-3.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c26dd9834e74d164d06433dc7be5d75a1e9890b926b3e57e74fa446e1a62c3e2", size = 8218223 },
{ url = "https://files.pythonhosted.org/packages/5c/08/30a94afd828b6e02d0a52cae4a29d6e9ccfcf4c8b56cc28b021d3588873e/matplotlib-3.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:24853dad5b8c84c8c2390fc31ce4858b6df504156893292ce8092d190ef8151d", size = 8094985 },
{ url = "https://files.pythonhosted.org/packages/89/44/f3bc6b53066c889d7a1a3ea8094c13af6a667c5ca6220ec60ecceec2dabe/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f7878214d369d7d4215e2a9075fef743be38fa401d32e6020bab2dfabaa566", size = 8483109 },
{ url = "https://files.pythonhosted.org/packages/ba/c7/473bc559beec08ebee9f86ca77a844b65747e1a6c2691e8c92e40b9f42a8/matplotlib-3.10.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6929fc618cb6db9cb75086f73b3219bbb25920cb24cee2ea7a12b04971a4158", size = 8618082 },
{ url = "https://files.pythonhosted.org/packages/d8/e9/6ce8edd264c8819e37bbed8172e0ccdc7107fe86999b76ab5752276357a4/matplotlib-3.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7818292a5cc372a2dc4c795e5c356942eb8350b98ef913f7fda51fe175ac5d", size = 9413699 },
{ url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044 },
]
[[package]]
name = "matplotlib-inline"
version = "0.1.7"
@ -788,6 +956,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4d/23c4e4f09da849e127e9f123241946c23c1e30f45a88366879e064211815/mistune-3.1.3-py3-none-any.whl", hash = "sha256:1a32314113cff28aa6432e99e522677c8587fd83e3d51c29b82a52409c842bd9", size = 53410 },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 },
]
[[package]]
name = "nbclient"
version = "0.10.2"
@ -852,6 +1029,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 },
]
[[package]]
name = "networkx"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406 },
]
[[package]]
name = "notebook"
version = "7.4.3"
@ -908,6 +1094,149 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 },
]
[[package]]
name = "nvidia-cublas-cu12"
version = "12.8.3.14"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/df/4b01f10069e23c641f116c62fc31e31e8dc361a153175d81561d15c8143b/nvidia_cublas_cu12-12.8.3.14-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:3f0e05e7293598cf61933258b73e66a160c27d59c4422670bf0b79348c04be44", size = 609620630 },
]
[[package]]
name = "nvidia-cuda-cupti-cu12"
version = "12.8.57"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/6f/3683ecf4e38931971946777d231c2df00dd5c1c4c2c914c42ad8f9f4dca6/nvidia_cuda_cupti_cu12-12.8.57-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e0b2eb847de260739bee4a3f66fac31378f4ff49538ff527a38a01a9a39f950", size = 10237547 },
]
[[package]]
name = "nvidia-cuda-nvrtc-cu12"
version = "12.8.61"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/22/32029d4583f7b19cfe75c84399cbcfd23f2aaf41c66fc8db4da460104fff/nvidia_cuda_nvrtc_cu12-12.8.61-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a0fa9c2a21583105550ebd871bd76e2037205d56f33f128e69f6d2a55e0af9ed", size = 88024585 },
]
[[package]]
name = "nvidia-cuda-runtime-cu12"
version = "12.8.57"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/f6/0e1ef31f4753a44084310ba1a7f0abaf977ccd810a604035abb43421c057/nvidia_cuda_runtime_cu12-12.8.57-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75342e28567340b7428ce79a5d6bb6ca5ff9d07b69e7ce00d2c7b4dc23eff0be", size = 954762 },
]
[[package]]
name = "nvidia-cudnn-cu12"
version = "9.7.1.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/dc/dc825c4b1c83b538e207e34f48f86063c88deaa35d46c651c7c181364ba2/nvidia_cudnn_cu12-9.7.1.26-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:6d011159a158f3cfc47bf851aea79e31bcff60d530b70ef70474c84cac484d07", size = 726851421 },
]
[[package]]
name = "nvidia-cufft-cu12"
version = "11.3.3.41"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/26/b53c493c38dccb1f1a42e1a21dc12cba2a77fbe36c652f7726d9ec4aba28/nvidia_cufft_cu12-11.3.3.41-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:da650080ab79fcdf7a4b06aa1b460e99860646b176a43f6208099bdc17836b6a", size = 193118795 },
]
[[package]]
name = "nvidia-cufile-cu12"
version = "1.13.0.11"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/9c/1f3264d0a84c8a031487fb7f59780fc78fa6f1c97776233956780e3dc3ac/nvidia_cufile_cu12-1.13.0.11-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:483f434c541806936b98366f6d33caef5440572de8ddf38d453213729da3e7d4", size = 1197801 },
]
[[package]]
name = "nvidia-curand-cu12"
version = "10.3.9.55"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/fc/7be5d0082507269bb04ac07cc614c84b78749efb96e8cf4100a8a1178e98/nvidia_curand_cu12-10.3.9.55-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8387d974240c91f6a60b761b83d4b2f9b938b7e0b9617bae0f0dafe4f5c36b86", size = 63618038 },
]
[[package]]
name = "nvidia-cusolver-cu12"
version = "11.7.2.55"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" },
{ name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" },
{ name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/08/953675873a136d96bb12f93b49ba045d1107bc94d2551c52b12fa6c7dec3/nvidia_cusolver_cu12-11.7.2.55-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4d1354102f1e922cee9db51920dba9e2559877cf6ff5ad03a00d853adafb191b", size = 260373342 },
]
[[package]]
name = "nvidia-cusparse-cu12"
version = "12.5.7.53"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or sys_platform == 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/ab/31e8149c66213b846c082a3b41b1365b831f41191f9f40c6ddbc8a7d550e/nvidia_cusparse_cu12-12.5.7.53-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c1b61eb8c85257ea07e9354606b26397612627fdcd327bfd91ccf6155e7c86d", size = 292064180 },
]
[[package]]
name = "nvidia-cusparselt-cu12"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/9a/72ef35b399b0e183bc2e8f6f558036922d453c4d8237dab26c666a04244b/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46", size = 156785796 },
]
[[package]]
name = "nvidia-nccl-cu12"
version = "2.26.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/ca/f42388aed0fddd64ade7493dbba36e1f534d4e6fdbdd355c6a90030ae028/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6", size = 201319755 },
]
[[package]]
name = "nvidia-nvjitlink-cu12"
version = "12.8.61"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/f8/9d85593582bd99b8d7c65634d2304780aefade049b2b94d96e44084be90b/nvidia_nvjitlink_cu12-12.8.61-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:45fd79f2ae20bd67e8bc411055939049873bfd8fac70ff13bd4865e0b9bdab17", size = 39243473 },
]
[[package]]
name = "nvidia-nvtx-cu12"
version = "12.8.55"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/cd/0e8c51b2ae3a58f054f2e7fe91b82d201abfb30167f2431e9bd92d532f42/nvidia_nvtx_cu12-12.8.55-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dd0780f1a55c21d8e06a743de5bd95653de630decfff40621dbde78cc307102", size = 89896 },
]
[[package]]
name = "opencv-python"
version = "4.11.0.86"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322 },
{ url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197 },
{ url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439 },
{ url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597 },
{ url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337 },
{ url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044 },
]
[[package]]
name = "overrides"
version = "7.7.0"
@ -1085,6 +1414,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
[[package]]
name = "pyparsing"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1272,6 +1610,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/97/5a4b59697111c89477d20ba8a44df9ca16b41e737fa569d5ae8bff99e650/rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763", size = 232218 },
]
[[package]]
name = "scikit-image"
version = "0.25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "imageio" },
{ name = "lazy-loader" },
{ name = "networkx" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "scipy" },
{ name = "tifffile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841 },
{ url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862 },
{ url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785 },
{ url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119 },
{ url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116 },
{ url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801 },
]
[[package]]
name = "scipy"
version = "1.15.3"
@ -1360,13 +1722,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 },
]
[[package]]
name = "terminado"
version = "0.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess", marker = "os_name != 'nt'" },
{ name = "pywinpty", marker = "os_name == 'nt'" },
{ name = "pywinpty", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "tornado" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701 }
@ -1374,6 +1748,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154 },
]
[[package]]
name = "tifffile"
version = "2025.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/cc/deed7dd69d4029adba8e95214f8bf65fca8bc6b8426e27d056e1de624206/tifffile-2025.6.1.tar.gz", hash = "sha256:63cff7cf7305c26e3f3451c0b05fd95a09252beef4f1663227d4b70cb75c5fdb", size = 369769 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/77/7f7dfcf2d847c1c1c63a2d4157c480eb4c74e4aa56e844008795ff01f86d/tifffile-2025.6.1-py3-none-any.whl", hash = "sha256:ff7163f1aaea519b769a2ac77c43be69e7d83e5b5d5d6a676497399de50535e5", size = 230624 },
]
[[package]]
name = "tinycss2"
version = "1.4.0"
@ -1386,6 +1772,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 },
]
[[package]]
name = "torch"
version = "2.7.1+cu128"
source = { registry = "https://download.pytorch.org/whl/cu128" }
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d56d29a6ad7758ba5173cc2b0c51c93e126e2b0a918e874101dc66545283967f" },
{ url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9560425f9ea1af1791507e8ca70d5b9ecf62fed7ca226a95fcd58d0eb2cca78f" },
{ url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp313-cp313-win_amd64.whl", hash = "sha256:500ad5b670483f62d4052e41948a3fb19e8c8de65b99f8d418d879cbb15a82d6" },
{ url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f112465fdf42eb1297c6dddda1a8b7f411914428b704e1b8a47870c52e290909" },
{ url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c355db49c218ada70321d5c5c9bb3077312738b99113c8f3723ef596b554a7b9" },
{ url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp313-cp313t-win_amd64.whl", hash = "sha256:e27e5f7e74179fb5d814a0412e5026e4b50c9e0081e9050bc4c28c992a276eb1" },
]
[[package]]
name = "tornado"
version = "6.5.1"
@ -1414,6 +1837,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 },
]
[[package]]
name = "triton"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/1f/dfb531f90a2d367d914adfee771babbd3f1a5b26c3f5fbc458dee21daa78/triton-3.3.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b89d846b5a4198317fec27a5d3a609ea96b6d557ff44b56c23176546023c4240", size = 155673035 },
{ url = "https://files.pythonhosted.org/packages/28/71/bd20ffcb7a64c753dc2463489a61bf69d531f308e390ad06390268c4ea04/triton-3.3.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3198adb9d78b77818a5388bff89fa72ff36f9da0bc689db2f0a651a67ce6a42", size = 155735832 },
]
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20250516"

242
wb.py Normal file
View File

@ -0,0 +1,242 @@
import torch
import numpy as np
import imageio.v2 as imageio
import time
import os
# --- Configuration & Constants ---
# These are the same as the NumPy version but will be used on PyTorch tensors.
DEFAULT_T_THRESHOLD = 0.1321
DEFAULT_MU_STEP = 0.0312
SCALE_FACTOR = 255.0
DEFAULT_A_THRESHOLD = 0.8 / SCALE_FACTOR
DEFAULT_B_THRESHOLD = 0.15 / SCALE_FACTOR
DEFAULT_MAX_ITERATIONS = 60
# --- PyTorch Core Algorithm Functions ---
def rgb_to_yuv_torch(image_tensor: torch.Tensor, matrix_torch: torch.Tensor) -> torch.Tensor:
"""
Converts an RGB image tensor to the paper's YUV color space using PyTorch.
Args:
image_tensor (torch.Tensor): A (H, W, 3) tensor on a target device.
matrix_torch (torch.Tensor): The 3x3 conversion matrix on the same device.
Returns:
torch.Tensor: An (H, W, 3) YUV tensor on the same device.
"""
return torch.matmul(image_tensor, matrix_torch)
def k_function_torch(error: float, a: float, b: float) -> float:
"""
Implements the non-linear error weighting function K(x) from Eq. 16.
This function remains on the CPU as it operates on a single scalar value.
"""
abs_error = abs(error)
sign = np.sign(error)
if abs_error >= a:
return 2.0 * sign
elif abs_error >= b:
return 1.0 * sign
else:
return 0.0
def huo_awb_core_torch(image_tensor: torch.Tensor,
t_threshold: float,
mu: float,
a: float,
b: float,
max_iter: int,
device: torch.device) -> torch.Tensor:
"""
Performs the core iterative AWB algorithm using PyTorch tensors on a specified device.
Args:
image_tensor (torch.Tensor): Input image as a (H, W, 3) float32 tensor on the target device.
(other params): Algorithm configuration constants.
device (torch.device): The device (e.g., 'cuda' or 'cpu') to run on.
Returns:
torch.Tensor: A (3,) tensor containing the final calculated [R, G, B] gains.
"""
# Create the YUV conversion matrix and gains tensor on the target device
yuv_matrix = torch.tensor([
[0.299, 0.587, 0.114],
[-0.299, -0.587, 0.886],
[0.701, -0.587, -0.114]
], dtype=torch.float32, device=device).T
gains = torch.tensor([1.0, 1.0, 1.0], dtype=torch.float32, device=device)
print(f"Starting iterative AWB on device: '{device.type}'...")
for i in range(max_iter):
# 1. Apply current gains to the image (all on GPU/device)
balanced_image = torch.clamp(image_tensor * gains, 0.0, 1.0)
# 2. Convert to YUV (on GPU/device)
yuv_image = rgb_to_yuv_torch(balanced_image, yuv_matrix)
Y, U, V = yuv_image.unbind(dim=-1)
# 3. Identify gray points (on GPU/device)
# Luminance mask to exclude overly dark or bright pixels
luminance_mask = (Y > 0.1) & (Y < 0.95)
if not torch.any(luminance_mask):
print(f"Iteration {i+1}: No pixels in luminance range. Stopping.")
break
Y_masked = Y[luminance_mask]
U_masked = U[luminance_mask]
V_masked = V[luminance_mask]
# Criterion from Eq. 10
gray_mask_indices = (torch.abs(U_masked) + torch.abs(V_masked)) / Y_masked < t_threshold
gray_points_U = U_masked[gray_mask_indices]
num_gray_points = gray_points_U.shape[0]
if num_gray_points < 50: # Use a higher threshold for large images
print(f"Iteration {i+1}: Not enough gray points found ({num_gray_points}). Stopping.")
break
# 4. Calculate average chrominance (reduction on GPU/device)
u_mean = torch.mean(gray_points_U)
v_mean = torch.mean(V_masked[gray_mask_indices])
# Bring the scalar results back to CPU for control flow
u_mean_cpu = u_mean.item()
v_mean_cpu = v_mean.item()
# Check for convergence
if abs(u_mean_cpu) < b and abs(v_mean_cpu) < b:
print(f"Iteration {i+1}: Converged. u_mean={u_mean_cpu:.4f}, v_mean={v_mean_cpu:.4f}")
break
# 5. Determine adjustment (logic on CPU, gain update on GPU/device)
if abs(u_mean_cpu) > abs(v_mean_cpu):
error = -u_mean_cpu
adjustment = mu * k_function_torch(error, a, b)
gains[2] += adjustment
print(f"Iter {i+1}: Adjusting B-gain. u_mean={u_mean_cpu:.4f}, v_mean={v_mean_cpu:.4f}, B-adj={adjustment:.4f}")
else:
error = -v_mean_cpu
adjustment = mu * k_function_torch(error, a, b)
gains[0] += adjustment
print(f"Iter {i+1}: Adjusting R-gain. u_mean={u_mean_cpu:.4f}, v_mean={v_mean_cpu:.4f}, R-adj={adjustment:.4f}")
print(f"Final gains: R={gains[0].item():.4f}, G={gains[1].item():.4f}, B={gains[2].item():.4f}")
return gains
# --- Main Public Function ---
def apply_huo_awb_torch(image_path: str, output_path: str, **kwargs):
"""
Loads a high-resolution 16-bit TIFF, applies the Huo et al. AWB algorithm
using PyTorch for high performance, and saves the result.
Args:
image_path (str): Path to the input 16-bit TIFF image.
output_path (str): Path to save the white-balanced 16-bit TIFF image.
**kwargs: Optional algorithm parameters.
"""
start_time = time.perf_counter()
# 1. Select Device (GPU if available, otherwise CPU)
# if torch.cuda.is_available():
# device = torch.device('cuda')
# elif torch.backends.mps.is_available(): # For Apple Silicon
# device = torch.device('mps')
# else:
device = torch.device('cpu')
print(f"Using device: {device}")
# 2. Load Image with imageio (on CPU)
print(f"Loading image from: {image_path}")
try:
image_np = imageio.imread(image_path)
except FileNotFoundError:
print(f"Error: The file '{image_path}' was not found.")
return
load_time = time.perf_counter()
print(f"Image loaded in {load_time - start_time:.2f} seconds.")
# 3. Pre-process and Move to Device
# Normalize to float32 and convert to PyTorch tensor
image_float_np = image_np.astype(np.float32) / 65535.0
# Move the large image tensor to the selected device
image_tensor = torch.from_numpy(image_float_np).to(device)
transfer_time = time.perf_counter()
print(f"Data transferred to {device.type} in {transfer_time - load_time:.2f} seconds.")
# 4. Run the core algorithm on the device
params = {
't_threshold': kwargs.get('t_threshold', DEFAULT_T_THRESHOLD),
'mu': kwargs.get('mu', DEFAULT_MU_STEP),
'a': kwargs.get('a', DEFAULT_A_THRESHOLD),
'b': kwargs.get('b', DEFAULT_B_THRESHOLD),
'max_iter': kwargs.get('max_iter', DEFAULT_MAX_ITERATIONS),
}
gains = huo_awb_core_torch(image_tensor, device=device, **params)
process_time = time.perf_counter()
print(f"AWB processing finished in {process_time - transfer_time:.2f} seconds.")
# 5. Apply final gains, move back to CPU, and save
corrected_image_tensor = torch.clamp(image_tensor * gains, 0.0, 1.0)
# Move tensor back to CPU for conversion to NumPy
corrected_image_np = corrected_image_tensor.cpu().numpy()
# Convert back to 16-bit integer for saving
corrected_image_uint16 = (corrected_image_np * 65535).astype(np.uint16)
print(f"Saving corrected image to: {output_path}")
imageio.imwrite(output_path, corrected_image_uint16)
end_time = time.perf_counter()
print(f"Image saved. Total time: {end_time - start_time:.2f} seconds.")
# --- Example Usage ---
if __name__ == '__main__':
# Create a dummy 50MP, 16-bit TIFF with a bluish cast
# 50MP is approx. 8660 x 5773 pixels
h, w = 5773, 8660
print(f"Creating a sample {h*w/1e6:.1f}MP 16-bit TIFF image with a bluish cast...")
# A gray gradient (create a smaller version and resize to save memory/time)
small_w = w // 10
gray_base_small = np.linspace(0.2, 0.8, small_w, dtype=np.float32)
gray_image_small = np.tile(gray_base_small, (h // 10, 1))
# Use PyTorch to resize efficiently if possible, otherwise numpy/scipy
try:
import torch.nn.functional as F
gray_image = F.interpolate(
torch.from_numpy(gray_image_small)[None, None, ...],
size=(h, w),
mode='bilinear',
align_corners=False
)[0, 0, ...].numpy()
except (ImportError, ModuleNotFoundError):
print("Resizing with a simpler method as full torch/cv2 not available for generation.")
gray_image = np.tile(np.linspace(0.2, 0.8, w, dtype=np.float32), (h, 1))
image_float = np.stack([gray_image, gray_image, gray_image], axis=-1)
# Apply a bluish cast (decrease R, increase B)
blue_cast = np.array([0.85, 1.0, 1.15], dtype=np.float32)
image_float_cast = np.clip(image_float * blue_cast, 0, 1)
image_uint16_cast = (image_float_cast * 65535).astype(np.uint16)
input_filename = "/home/dubey/projects/filmsim/test_images/v1.3output/filmscan/04_portra_400_border_v3colorxyz.tiff"
output_filename = "/home/dubey/projects/filmsim/test_images/v1.3output/filmscan/04_portra_400_border_v3colorxyz_corrected.tiff"
# Run the white balance algorithm on the sample image
apply_huo_awb_torch(input_filename, output_filename)