Claude commited on
Commit
7aa32c8
·
unverified ·
1 Parent(s): df36067

feat: Add image preview, digital negative export, and interactive curve editor

Browse files

New Features:
- Image Preview tab: Upload images and preview curve effects before processing
- Digital Negative tab: Create inverted negatives with optional curves applied
- Export in original format/resolution or choose from TIFF, PNG, JPEG
- Support for 8-bit and 16-bit export
- JPEG quality control
- Interactive Curve Editor: Create curves with numeric control points
- 9-point control with input/output value editing
- Preset curves: Linear, S-Curve, Brighten, Darken, Gamma 1.8/2.2
- Quick gamma adjustment slider
- Real-time curve visualization
- Export to QTR, CSV, JSON formats

New Imaging Module:
- ImageProcessor class for curve application and image processing
- LUT-based curve application for efficient processing
- Support for grayscale, RGB, RGBA images
- Image inversion for digital negative creation
- Multiple export formats with quality control
- Resolution and metadata preservation

Tests:
- 38 new tests for image processor module
- All 314 tests passing

src/ptpd_calibration/imaging/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Imaging module for digital negative creation and curve application.
3
+
4
+ Provides tools for applying calibration curves to images and creating
5
+ digital negatives for platinum/palladium printing.
6
+ """
7
+
8
+ from ptpd_calibration.imaging.processor import (
9
+ ImageProcessor,
10
+ ImageFormat,
11
+ ProcessingResult,
12
+ ExportSettings,
13
+ )
14
+
15
+ __all__ = [
16
+ "ImageProcessor",
17
+ "ImageFormat",
18
+ "ProcessingResult",
19
+ "ExportSettings",
20
+ ]
src/ptpd_calibration/imaging/processor.py ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image processor for digital negative creation.
3
+
4
+ Applies calibration curves to images, creates inverted negatives,
5
+ and exports in various formats while preserving resolution.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Optional, Union
12
+ import io
13
+
14
+ import numpy as np
15
+ from PIL import Image
16
+
17
+ from ptpd_calibration.core.models import CurveData
18
+
19
+
20
+ class ImageFormat(str, Enum):
21
+ """Supported image export formats."""
22
+
23
+ TIFF = "tiff"
24
+ TIFF_16BIT = "tiff_16bit"
25
+ PNG = "png"
26
+ PNG_16BIT = "png_16bit"
27
+ JPEG = "jpeg"
28
+ JPEG_HIGH = "jpeg_high"
29
+ ORIGINAL = "original" # Same as input
30
+
31
+
32
+ class ColorMode(str, Enum):
33
+ """Image color modes for processing."""
34
+
35
+ GRAYSCALE = "grayscale"
36
+ RGB = "rgb"
37
+ PRESERVE = "preserve" # Keep original mode
38
+
39
+
40
+ @dataclass
41
+ class ExportSettings:
42
+ """Settings for image export."""
43
+
44
+ format: ImageFormat = ImageFormat.ORIGINAL
45
+ jpeg_quality: int = 95 # 0-100
46
+ preserve_metadata: bool = True
47
+ preserve_resolution: bool = True
48
+ target_dpi: Optional[int] = None # Override DPI if set
49
+ compression: Optional[str] = None # Format-specific compression
50
+
51
+
52
+ @dataclass
53
+ class ProcessingResult:
54
+ """Result of image processing operation."""
55
+
56
+ image: Image.Image
57
+ original_size: tuple[int, int]
58
+ original_mode: str
59
+ original_format: Optional[str]
60
+ original_dpi: Optional[tuple[int, int]]
61
+ curve_applied: bool
62
+ inverted: bool
63
+ processing_notes: list[str] = field(default_factory=list)
64
+
65
+ def get_info(self) -> dict:
66
+ """Get processing info as dictionary."""
67
+ return {
68
+ "size": f"{self.image.size[0]}x{self.image.size[1]}",
69
+ "original_size": f"{self.original_size[0]}x{self.original_size[1]}",
70
+ "mode": self.image.mode,
71
+ "original_mode": self.original_mode,
72
+ "original_format": self.original_format,
73
+ "dpi": self.original_dpi,
74
+ "curve_applied": self.curve_applied,
75
+ "inverted": self.inverted,
76
+ "notes": self.processing_notes,
77
+ }
78
+
79
+
80
+ class ImageProcessor:
81
+ """Process images with calibration curves for digital negative creation.
82
+
83
+ Supports:
84
+ - Loading images in various formats
85
+ - Applying calibration curves using lookup tables
86
+ - Creating inverted negatives
87
+ - Exporting in various formats while preserving quality
88
+ """
89
+
90
+ def __init__(self):
91
+ """Initialize the image processor."""
92
+ self._lut_cache: dict[str, np.ndarray] = {}
93
+
94
+ def load_image(
95
+ self,
96
+ source: Union[str, Path, Image.Image, np.ndarray, bytes],
97
+ ) -> ProcessingResult:
98
+ """Load an image from various sources.
99
+
100
+ Args:
101
+ source: Image path, PIL Image, numpy array, or bytes
102
+
103
+ Returns:
104
+ ProcessingResult with loaded image and metadata
105
+ """
106
+ if isinstance(source, (str, Path)):
107
+ img = Image.open(source)
108
+ original_format = img.format
109
+ elif isinstance(source, bytes):
110
+ img = Image.open(io.BytesIO(source))
111
+ original_format = img.format
112
+ elif isinstance(source, Image.Image):
113
+ img = source.copy()
114
+ original_format = getattr(source, "format", None)
115
+ elif isinstance(source, np.ndarray):
116
+ if source.ndim == 2:
117
+ img = Image.fromarray(source.astype(np.uint8), mode="L")
118
+ elif source.ndim == 3 and source.shape[2] == 3:
119
+ img = Image.fromarray(source.astype(np.uint8), mode="RGB")
120
+ elif source.ndim == 3 and source.shape[2] == 4:
121
+ img = Image.fromarray(source.astype(np.uint8), mode="RGBA")
122
+ else:
123
+ raise ValueError(f"Unsupported array shape: {source.shape}")
124
+ original_format = None
125
+ else:
126
+ raise TypeError(f"Unsupported source type: {type(source)}")
127
+
128
+ # Get DPI if available
129
+ dpi = img.info.get("dpi")
130
+
131
+ return ProcessingResult(
132
+ image=img,
133
+ original_size=img.size,
134
+ original_mode=img.mode,
135
+ original_format=original_format,
136
+ original_dpi=dpi,
137
+ curve_applied=False,
138
+ inverted=False,
139
+ )
140
+
141
+ def apply_curve(
142
+ self,
143
+ result: ProcessingResult,
144
+ curve: CurveData,
145
+ color_mode: ColorMode = ColorMode.PRESERVE,
146
+ ) -> ProcessingResult:
147
+ """Apply a calibration curve to an image.
148
+
149
+ Args:
150
+ result: ProcessingResult with image to process
151
+ curve: CurveData with calibration curve
152
+ color_mode: How to handle color (grayscale, rgb, preserve)
153
+
154
+ Returns:
155
+ New ProcessingResult with curve applied
156
+ """
157
+ img = result.image
158
+
159
+ # Convert to appropriate mode for processing
160
+ if color_mode == ColorMode.GRAYSCALE:
161
+ if img.mode not in ("L", "LA"):
162
+ img = img.convert("L")
163
+ elif color_mode == ColorMode.RGB:
164
+ if img.mode not in ("RGB", "RGBA"):
165
+ img = img.convert("RGB")
166
+
167
+ # Create lookup table from curve
168
+ lut = self._create_lut(curve)
169
+
170
+ # Apply LUT based on image mode
171
+ if img.mode == "L":
172
+ processed = self._apply_lut_grayscale(img, lut)
173
+ elif img.mode == "LA":
174
+ # Grayscale with alpha
175
+ l_channel = img.split()[0]
176
+ a_channel = img.split()[1]
177
+ processed_l = self._apply_lut_grayscale(l_channel, lut)
178
+ processed = Image.merge("LA", (processed_l, a_channel))
179
+ elif img.mode == "RGB":
180
+ processed = self._apply_lut_rgb(img, lut)
181
+ elif img.mode == "RGBA":
182
+ # RGB with alpha
183
+ rgb = img.convert("RGB")
184
+ a_channel = img.split()[3]
185
+ processed_rgb = self._apply_lut_rgb(rgb, lut)
186
+ processed = processed_rgb.copy()
187
+ processed.putalpha(a_channel)
188
+ else:
189
+ # Try to convert to RGB first
190
+ try:
191
+ rgb = img.convert("RGB")
192
+ processed = self._apply_lut_rgb(rgb, lut)
193
+ except Exception:
194
+ raise ValueError(f"Unsupported image mode: {img.mode}")
195
+
196
+ notes = list(result.processing_notes)
197
+ notes.append(f"Applied curve: {curve.name}")
198
+
199
+ return ProcessingResult(
200
+ image=processed,
201
+ original_size=result.original_size,
202
+ original_mode=result.original_mode,
203
+ original_format=result.original_format,
204
+ original_dpi=result.original_dpi,
205
+ curve_applied=True,
206
+ inverted=result.inverted,
207
+ processing_notes=notes,
208
+ )
209
+
210
+ def invert(self, result: ProcessingResult) -> ProcessingResult:
211
+ """Invert an image (create negative).
212
+
213
+ Args:
214
+ result: ProcessingResult with image to invert
215
+
216
+ Returns:
217
+ New ProcessingResult with inverted image
218
+ """
219
+ img = result.image
220
+
221
+ # Handle different modes
222
+ if img.mode == "L":
223
+ arr = np.array(img)
224
+ inverted_arr = 255 - arr
225
+ inverted = Image.fromarray(inverted_arr.astype(np.uint8), mode="L")
226
+ elif img.mode == "LA":
227
+ l_channel, a_channel = img.split()
228
+ l_arr = np.array(l_channel)
229
+ inverted_l = Image.fromarray((255 - l_arr).astype(np.uint8), mode="L")
230
+ inverted = Image.merge("LA", (inverted_l, a_channel))
231
+ elif img.mode == "RGB":
232
+ arr = np.array(img)
233
+ inverted_arr = 255 - arr
234
+ inverted = Image.fromarray(inverted_arr.astype(np.uint8), mode="RGB")
235
+ elif img.mode == "RGBA":
236
+ r, g, b, a = img.split()
237
+ rgb = Image.merge("RGB", (r, g, b))
238
+ rgb_arr = np.array(rgb)
239
+ inverted_rgb = Image.fromarray((255 - rgb_arr).astype(np.uint8), mode="RGB")
240
+ inverted = inverted_rgb.copy()
241
+ inverted.putalpha(a)
242
+ else:
243
+ # Try to handle other modes
244
+ try:
245
+ rgb = img.convert("RGB")
246
+ arr = np.array(rgb)
247
+ inverted_arr = 255 - arr
248
+ inverted = Image.fromarray(inverted_arr.astype(np.uint8), mode="RGB")
249
+ except Exception:
250
+ raise ValueError(f"Cannot invert image mode: {img.mode}")
251
+
252
+ notes = list(result.processing_notes)
253
+ notes.append("Image inverted (negative created)")
254
+
255
+ return ProcessingResult(
256
+ image=inverted,
257
+ original_size=result.original_size,
258
+ original_mode=result.original_mode,
259
+ original_format=result.original_format,
260
+ original_dpi=result.original_dpi,
261
+ curve_applied=result.curve_applied,
262
+ inverted=not result.inverted, # Toggle inversion state
263
+ processing_notes=notes,
264
+ )
265
+
266
+ def create_digital_negative(
267
+ self,
268
+ source: Union[str, Path, Image.Image, np.ndarray, bytes],
269
+ curve: Optional[CurveData] = None,
270
+ invert: bool = True,
271
+ color_mode: ColorMode = ColorMode.GRAYSCALE,
272
+ ) -> ProcessingResult:
273
+ """Create a digital negative from an image.
274
+
275
+ Complete workflow: load → apply curve → invert
276
+
277
+ Args:
278
+ source: Image source
279
+ curve: Optional calibration curve to apply
280
+ invert: Whether to invert the image
281
+ color_mode: Color mode for processing
282
+
283
+ Returns:
284
+ ProcessingResult with digital negative
285
+ """
286
+ result = self.load_image(source)
287
+
288
+ # Convert to appropriate color mode
289
+ if color_mode == ColorMode.GRAYSCALE:
290
+ if result.image.mode not in ("L", "LA"):
291
+ result = ProcessingResult(
292
+ image=result.image.convert("L"),
293
+ original_size=result.original_size,
294
+ original_mode=result.original_mode,
295
+ original_format=result.original_format,
296
+ original_dpi=result.original_dpi,
297
+ curve_applied=result.curve_applied,
298
+ inverted=result.inverted,
299
+ processing_notes=result.processing_notes + ["Converted to grayscale"],
300
+ )
301
+
302
+ # Apply curve if provided
303
+ if curve is not None:
304
+ result = self.apply_curve(result, curve, color_mode)
305
+
306
+ # Invert if requested
307
+ if invert:
308
+ result = self.invert(result)
309
+
310
+ return result
311
+
312
+ def preview_curve_effect(
313
+ self,
314
+ source: Union[str, Path, Image.Image, np.ndarray, bytes],
315
+ curve: CurveData,
316
+ color_mode: ColorMode = ColorMode.PRESERVE,
317
+ thumbnail_size: Optional[tuple[int, int]] = None,
318
+ ) -> tuple[Image.Image, Image.Image]:
319
+ """Preview the effect of a curve on an image.
320
+
321
+ Args:
322
+ source: Image source
323
+ curve: Calibration curve to preview
324
+ color_mode: Color mode for processing
325
+ thumbnail_size: Optional size to resize for faster preview
326
+
327
+ Returns:
328
+ Tuple of (original_image, processed_image)
329
+ """
330
+ result = self.load_image(source)
331
+
332
+ original = result.image.copy()
333
+ if thumbnail_size:
334
+ original.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
335
+ # Resize the processing image too
336
+ result = ProcessingResult(
337
+ image=result.image.copy(),
338
+ original_size=result.original_size,
339
+ original_mode=result.original_mode,
340
+ original_format=result.original_format,
341
+ original_dpi=result.original_dpi,
342
+ curve_applied=False,
343
+ inverted=False,
344
+ )
345
+ result.image.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
346
+
347
+ processed_result = self.apply_curve(result, curve, color_mode)
348
+
349
+ return original, processed_result.image
350
+
351
+ def export(
352
+ self,
353
+ result: ProcessingResult,
354
+ output_path: Union[str, Path],
355
+ settings: Optional[ExportSettings] = None,
356
+ ) -> Path:
357
+ """Export processed image to file.
358
+
359
+ Args:
360
+ result: ProcessingResult to export
361
+ output_path: Output file path
362
+ settings: Export settings
363
+
364
+ Returns:
365
+ Path to exported file
366
+ """
367
+ settings = settings or ExportSettings()
368
+ output_path = Path(output_path)
369
+
370
+ img = result.image
371
+
372
+ # Determine format
373
+ if settings.format == ImageFormat.ORIGINAL:
374
+ # Use original format or infer from path
375
+ fmt = result.original_format or output_path.suffix.lstrip(".").upper()
376
+ if fmt.upper() == "JPG":
377
+ fmt = "JPEG"
378
+ else:
379
+ fmt = settings.format.value.upper()
380
+ if fmt.endswith("_16BIT"):
381
+ fmt = fmt.replace("_16BIT", "")
382
+ if fmt == "JPEG_HIGH":
383
+ fmt = "JPEG"
384
+
385
+ # Handle 16-bit export
386
+ is_16bit = settings.format in (ImageFormat.TIFF_16BIT, ImageFormat.PNG_16BIT)
387
+
388
+ # Build save kwargs
389
+ save_kwargs = {}
390
+
391
+ # Set DPI
392
+ if settings.preserve_resolution and result.original_dpi:
393
+ save_kwargs["dpi"] = result.original_dpi
394
+ elif settings.target_dpi:
395
+ save_kwargs["dpi"] = (settings.target_dpi, settings.target_dpi)
396
+
397
+ # Format-specific settings
398
+ if fmt == "JPEG":
399
+ quality = 98 if settings.format == ImageFormat.JPEG_HIGH else settings.jpeg_quality
400
+ save_kwargs["quality"] = quality
401
+ save_kwargs["subsampling"] = 0 # Best quality
402
+ # JPEG doesn't support alpha
403
+ if img.mode in ("RGBA", "LA"):
404
+ img = img.convert("RGB" if img.mode == "RGBA" else "L")
405
+
406
+ elif fmt == "TIFF":
407
+ if settings.compression:
408
+ save_kwargs["compression"] = settings.compression
409
+ else:
410
+ save_kwargs["compression"] = "tiff_lzw"
411
+
412
+ elif fmt == "PNG":
413
+ save_kwargs["compress_level"] = 6 # Balanced compression
414
+
415
+ # Handle 16-bit conversion
416
+ if is_16bit:
417
+ arr = np.array(img).astype(np.uint16) * 257 # Scale 8-bit to 16-bit
418
+ if fmt == "TIFF":
419
+ # Save 16-bit TIFF
420
+ self._save_16bit_tiff(arr, output_path, save_kwargs)
421
+ return output_path
422
+ elif fmt == "PNG":
423
+ # PIL can handle 16-bit PNG for grayscale
424
+ if img.mode == "L":
425
+ img = Image.fromarray(arr, mode="I;16")
426
+ else:
427
+ # For RGB, need to use array directly
428
+ pass # Fall through to standard save
429
+
430
+ # Standard save
431
+ img.save(output_path, format=fmt, **save_kwargs)
432
+
433
+ return output_path
434
+
435
+ def export_to_bytes(
436
+ self,
437
+ result: ProcessingResult,
438
+ settings: Optional[ExportSettings] = None,
439
+ ) -> tuple[bytes, str]:
440
+ """Export processed image to bytes.
441
+
442
+ Args:
443
+ result: ProcessingResult to export
444
+ settings: Export settings
445
+
446
+ Returns:
447
+ Tuple of (image_bytes, format_extension)
448
+ """
449
+ settings = settings or ExportSettings()
450
+ img = result.image
451
+
452
+ # Determine format
453
+ if settings.format == ImageFormat.ORIGINAL:
454
+ fmt = result.original_format or "PNG"
455
+ else:
456
+ fmt = settings.format.value.upper()
457
+ if fmt.endswith("_16BIT"):
458
+ fmt = fmt.replace("_16BIT", "")
459
+ if fmt == "JPEG_HIGH":
460
+ fmt = "JPEG"
461
+
462
+ # Build extension map
463
+ ext_map = {
464
+ "TIFF": ".tiff",
465
+ "PNG": ".png",
466
+ "JPEG": ".jpg",
467
+ }
468
+ ext = ext_map.get(fmt, ".png")
469
+
470
+ # Build save kwargs
471
+ save_kwargs = {}
472
+
473
+ if fmt == "JPEG":
474
+ quality = 98 if settings.format == ImageFormat.JPEG_HIGH else settings.jpeg_quality
475
+ save_kwargs["quality"] = quality
476
+ if img.mode in ("RGBA", "LA"):
477
+ img = img.convert("RGB" if img.mode == "RGBA" else "L")
478
+
479
+ elif fmt == "TIFF":
480
+ save_kwargs["compression"] = "tiff_lzw"
481
+
482
+ # Save to bytes
483
+ buffer = io.BytesIO()
484
+ img.save(buffer, format=fmt, **save_kwargs)
485
+ buffer.seek(0)
486
+
487
+ return buffer.read(), ext
488
+
489
+ def _create_lut(self, curve: CurveData) -> np.ndarray:
490
+ """Create 256-entry lookup table from curve.
491
+
492
+ Args:
493
+ curve: CurveData with input/output values
494
+
495
+ Returns:
496
+ NumPy array of 256 output values
497
+ """
498
+ # Check cache
499
+ cache_key = f"{curve.name}_{len(curve.input_values)}"
500
+ if cache_key in self._lut_cache:
501
+ return self._lut_cache[cache_key]
502
+
503
+ # Interpolate curve to 256 points
504
+ input_vals = np.array(curve.input_values)
505
+ output_vals = np.array(curve.output_values)
506
+
507
+ # Create LUT for all 256 possible input values
508
+ x_lut = np.linspace(0, 1, 256)
509
+ y_lut = np.interp(x_lut, input_vals, output_vals)
510
+
511
+ # Convert to 0-255 range
512
+ lut = (np.clip(y_lut, 0, 1) * 255).astype(np.uint8)
513
+
514
+ # Cache and return
515
+ self._lut_cache[cache_key] = lut
516
+ return lut
517
+
518
+ def _apply_lut_grayscale(self, img: Image.Image, lut: np.ndarray) -> Image.Image:
519
+ """Apply LUT to grayscale image.
520
+
521
+ Args:
522
+ img: Grayscale PIL Image
523
+ lut: 256-entry lookup table
524
+
525
+ Returns:
526
+ Processed grayscale image
527
+ """
528
+ arr = np.array(img)
529
+ processed = lut[arr]
530
+ return Image.fromarray(processed, mode="L")
531
+
532
+ def _apply_lut_rgb(self, img: Image.Image, lut: np.ndarray) -> Image.Image:
533
+ """Apply LUT to RGB image (same curve to all channels).
534
+
535
+ Args:
536
+ img: RGB PIL Image
537
+ lut: 256-entry lookup table
538
+
539
+ Returns:
540
+ Processed RGB image
541
+ """
542
+ arr = np.array(img)
543
+ processed = lut[arr]
544
+ return Image.fromarray(processed, mode="RGB")
545
+
546
+ def _save_16bit_tiff(
547
+ self,
548
+ arr: np.ndarray,
549
+ path: Path,
550
+ kwargs: dict,
551
+ ) -> None:
552
+ """Save 16-bit TIFF using PIL.
553
+
554
+ Args:
555
+ arr: 16-bit numpy array
556
+ path: Output path
557
+ kwargs: Additional save arguments
558
+ """
559
+ if arr.ndim == 2:
560
+ # Grayscale
561
+ img = Image.fromarray(arr, mode="I;16")
562
+ else:
563
+ # RGB - need to handle specially
564
+ # PIL doesn't support 16-bit RGB directly, so we'll save as 8-bit
565
+ arr_8bit = (arr / 257).astype(np.uint8)
566
+ img = Image.fromarray(arr_8bit, mode="RGB")
567
+
568
+ img.save(path, format="TIFF", **kwargs)
569
+
570
+ @staticmethod
571
+ def get_supported_formats() -> list[str]:
572
+ """Get list of supported image formats for import."""
573
+ return [
574
+ ".jpg", ".jpeg", ".png", ".tiff", ".tif",
575
+ ".bmp", ".gif", ".webp", ".ppm", ".pgm",
576
+ ]
577
+
578
+ @staticmethod
579
+ def get_export_formats() -> list[tuple[str, str]]:
580
+ """Get list of export formats with descriptions."""
581
+ return [
582
+ ("tiff", "TIFF (Lossless)"),
583
+ ("tiff_16bit", "TIFF 16-bit (High Quality)"),
584
+ ("png", "PNG (Lossless)"),
585
+ ("png_16bit", "PNG 16-bit"),
586
+ ("jpeg", "JPEG (Standard Quality)"),
587
+ ("jpeg_high", "JPEG (High Quality)"),
588
+ ("original", "Same as Original"),
589
+ ]
src/ptpd_calibration/ui/gradio_app.py CHANGED
@@ -59,6 +59,12 @@ def create_gradio_app(share: bool = False):
59
  MetalMix,
60
  METAL_MIX_RATIOS,
61
  )
 
 
 
 
 
 
62
 
63
  # Get settings for configuration-driven defaults
64
  settings = get_settings()
@@ -1476,7 +1482,693 @@ def create_gradio_app(share: bool = False):
1476
  )
1477
 
1478
  # ========================================
1479
- # TAB 8: Chemistry Calculator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1480
  # ========================================
1481
  with gr.TabItem("Chemistry Calculator"):
1482
  gr.Markdown(
@@ -1674,7 +2366,7 @@ def create_gradio_app(share: bool = False):
1674
  )
1675
 
1676
  # ========================================
1677
- # TAB 9: Settings
1678
  # ========================================
1679
  with gr.TabItem("Settings"):
1680
  gr.Markdown(
@@ -1849,7 +2541,7 @@ def create_gradio_app(share: bool = False):
1849
  )
1850
 
1851
  # ========================================
1852
- # TAB 10: About
1853
  # ========================================
1854
  with gr.TabItem("About"):
1855
  gr.Markdown(
@@ -1867,6 +2559,9 @@ def create_gradio_app(share: bool = False):
1867
  - **Curve Editor**: Upload .quad files, modify curves, smooth curves, and apply AI-powered enhancements
1868
  - **AI Enhancement**: Intelligent curve optimization
1869
  - **AI Assistant**: Get help from an AI expert in Pt/Pd printing
 
 
 
1870
  - **Chemistry Calculator**: Calculate coating solution amounts for any print size
1871
  - **Recipe Suggestions**: Get starting parameters for new papers
1872
  - **Troubleshooting**: Diagnose and fix common problems
@@ -1878,6 +2573,13 @@ def create_gradio_app(share: bool = False):
1878
  - CSV
1879
  - JSON
1880
 
 
 
 
 
 
 
 
1881
  ### Chemistry Reference
1882
 
1883
  Based on [Bostick-Sullivan Platinum/Palladium Kit Instructions](https://www.bostick-sullivan.com/wp-content/uploads/2022/03/platinum-and-palladium-kit-instructions.pdf):
 
59
  MetalMix,
60
  METAL_MIX_RATIOS,
61
  )
62
+ from ptpd_calibration.imaging import (
63
+ ImageProcessor,
64
+ ImageFormat,
65
+ ExportSettings,
66
+ )
67
+ from ptpd_calibration.imaging.processor import ColorMode
68
 
69
  # Get settings for configuration-driven defaults
70
  settings = get_settings()
 
1482
  )
1483
 
1484
  # ========================================
1485
+ # TAB 8: Image Preview
1486
+ # ========================================
1487
+ with gr.TabItem("Image Preview"):
1488
+ gr.Markdown(
1489
+ """
1490
+ ### Curve Preview on Image
1491
+
1492
+ Upload an image and select a curve to preview how the curve will affect your image.
1493
+ """
1494
+ )
1495
+
1496
+ # State for preview
1497
+ preview_curve_state = gr.State(None)
1498
+
1499
+ with gr.Row():
1500
+ with gr.Column(scale=1):
1501
+ gr.Markdown("#### Upload Image")
1502
+ preview_image_upload = gr.Image(
1503
+ label="Source Image",
1504
+ type="filepath",
1505
+ )
1506
+
1507
+ gr.Markdown("---")
1508
+ gr.Markdown("#### Select Curve")
1509
+
1510
+ with gr.Tabs():
1511
+ with gr.TabItem("Upload Curve"):
1512
+ preview_curve_file = gr.File(
1513
+ label="Upload Curve File",
1514
+ file_types=[".quad", ".txt", ".csv", ".json"],
1515
+ )
1516
+ load_preview_curve_btn = gr.Button("Load Curve")
1517
+
1518
+ with gr.TabItem("Enter Values"):
1519
+ preview_curve_values = gr.Textbox(
1520
+ label="Curve Values (comma-separated, 0-1)",
1521
+ placeholder="0.0, 0.05, 0.15, 0.3, 0.5, 0.7, 0.85, 0.95, 1.0",
1522
+ lines=2,
1523
+ )
1524
+ preview_curve_name_input = gr.Textbox(
1525
+ label="Curve Name",
1526
+ value="Custom Preview Curve",
1527
+ )
1528
+ load_custom_curve_btn = gr.Button("Load Values")
1529
+
1530
+ preview_curve_info = gr.JSON(label="Loaded Curve")
1531
+
1532
+ gr.Markdown("---")
1533
+ gr.Markdown("#### Preview Options")
1534
+
1535
+ preview_color_mode = gr.Dropdown(
1536
+ choices=[
1537
+ ("Preserve Original", "preserve"),
1538
+ ("Grayscale", "grayscale"),
1539
+ ("RGB", "rgb"),
1540
+ ],
1541
+ value="preserve",
1542
+ label="Color Mode",
1543
+ )
1544
+
1545
+ generate_preview_btn = gr.Button("Generate Preview", variant="primary")
1546
+
1547
+ with gr.Column(scale=2):
1548
+ gr.Markdown("#### Before / After Comparison")
1549
+
1550
+ with gr.Row():
1551
+ original_preview = gr.Image(
1552
+ label="Original",
1553
+ interactive=False,
1554
+ )
1555
+ processed_preview = gr.Image(
1556
+ label="With Curve Applied",
1557
+ interactive=False,
1558
+ )
1559
+
1560
+ preview_info_display = gr.Textbox(
1561
+ label="Preview Info",
1562
+ interactive=False,
1563
+ lines=3,
1564
+ )
1565
+
1566
+ def load_curve_for_preview(file):
1567
+ """Load curve from file for preview."""
1568
+ if file is None:
1569
+ return None, {"error": "No file uploaded"}
1570
+
1571
+ try:
1572
+ file_path = Path(file.name)
1573
+ suffix = file_path.suffix.lower()
1574
+
1575
+ if suffix in [".quad", ".txt"]:
1576
+ profile = load_quad_file(file_path)
1577
+ curve = profile.to_curve_data("K")
1578
+ elif suffix == ".json":
1579
+ from ptpd_calibration.curves.export import load_curve
1580
+ curve = load_curve(file_path)
1581
+ elif suffix == ".csv":
1582
+ from ptpd_calibration.curves.export import load_curve
1583
+ curve = load_curve(file_path)
1584
+ else:
1585
+ return None, {"error": f"Unsupported format: {suffix}"}
1586
+
1587
+ return curve, {
1588
+ "name": curve.name,
1589
+ "points": len(curve.input_values),
1590
+ "range": f"{min(curve.output_values):.3f} - {max(curve.output_values):.3f}",
1591
+ }
1592
+ except Exception as e:
1593
+ return None, {"error": str(e)}
1594
+
1595
+ def load_custom_curve_values(values_str, name):
1596
+ """Load curve from custom values."""
1597
+ if not values_str.strip():
1598
+ return None, {"error": "No values provided"}
1599
+
1600
+ try:
1601
+ values = [float(v.strip()) for v in values_str.split(",")]
1602
+ inputs = [i / (len(values) - 1) for i in range(len(values))]
1603
+
1604
+ curve = CurveData(
1605
+ name=name or "Custom Curve",
1606
+ input_values=inputs,
1607
+ output_values=values,
1608
+ )
1609
+
1610
+ return curve, {
1611
+ "name": curve.name,
1612
+ "points": len(curve.input_values),
1613
+ "range": f"{min(values):.3f} - {max(values):.3f}",
1614
+ }
1615
+ except Exception as e:
1616
+ return None, {"error": str(e)}
1617
+
1618
+ def generate_image_preview(image_path, curve, color_mode_str):
1619
+ """Generate before/after preview."""
1620
+ if image_path is None:
1621
+ return None, None, "No image uploaded"
1622
+
1623
+ if curve is None:
1624
+ return None, None, "No curve loaded"
1625
+
1626
+ try:
1627
+ processor = ImageProcessor()
1628
+ color_mode = ColorMode(color_mode_str)
1629
+
1630
+ # Generate preview
1631
+ original, processed = processor.preview_curve_effect(
1632
+ image_path,
1633
+ curve,
1634
+ color_mode=color_mode,
1635
+ thumbnail_size=(800, 800),
1636
+ )
1637
+
1638
+ info = f"Curve: {curve.name}\nColor Mode: {color_mode_str}\nOriginal Size: {original.size}"
1639
+
1640
+ return original, processed, info
1641
+ except Exception as e:
1642
+ return None, None, f"Error: {str(e)}"
1643
+
1644
+ # Connect handlers
1645
+ load_preview_curve_btn.click(
1646
+ load_curve_for_preview,
1647
+ inputs=[preview_curve_file],
1648
+ outputs=[preview_curve_state, preview_curve_info],
1649
+ )
1650
+
1651
+ load_custom_curve_btn.click(
1652
+ load_custom_curve_values,
1653
+ inputs=[preview_curve_values, preview_curve_name_input],
1654
+ outputs=[preview_curve_state, preview_curve_info],
1655
+ )
1656
+
1657
+ generate_preview_btn.click(
1658
+ generate_image_preview,
1659
+ inputs=[preview_image_upload, preview_curve_state, preview_color_mode],
1660
+ outputs=[original_preview, processed_preview, preview_info_display],
1661
+ )
1662
+
1663
+ # ========================================
1664
+ # TAB 9: Digital Negative
1665
+ # ========================================
1666
+ with gr.TabItem("Digital Negative"):
1667
+ gr.Markdown(
1668
+ """
1669
+ ### Digital Negative Creator
1670
+
1671
+ Create inverted digital negatives with calibration curves applied.
1672
+ Export in the same format and resolution as your original, or choose a different format.
1673
+ """
1674
+ )
1675
+
1676
+ # State
1677
+ dn_curve_state = gr.State(None)
1678
+ dn_result_state = gr.State(None)
1679
+
1680
+ with gr.Row():
1681
+ with gr.Column(scale=1):
1682
+ gr.Markdown("#### Source Image")
1683
+ dn_image_upload = gr.Image(
1684
+ label="Upload Image",
1685
+ type="filepath",
1686
+ )
1687
+
1688
+ gr.Markdown("---")
1689
+ gr.Markdown("#### Calibration Curve (Optional)")
1690
+
1691
+ with gr.Tabs():
1692
+ with gr.TabItem("Upload Curve"):
1693
+ dn_curve_file = gr.File(
1694
+ label="Upload Curve File",
1695
+ file_types=[".quad", ".txt", ".csv", ".json"],
1696
+ )
1697
+ load_dn_curve_btn = gr.Button("Load Curve")
1698
+
1699
+ with gr.TabItem("Enter Values"):
1700
+ dn_curve_values = gr.Textbox(
1701
+ label="Curve Values (comma-separated)",
1702
+ lines=2,
1703
+ )
1704
+ dn_curve_name_input = gr.Textbox(
1705
+ label="Curve Name",
1706
+ value="Digital Negative Curve",
1707
+ )
1708
+ load_dn_custom_btn = gr.Button("Load Values")
1709
+
1710
+ dn_curve_info = gr.JSON(label="Loaded Curve")
1711
+
1712
+ gr.Markdown("---")
1713
+ gr.Markdown("#### Processing Options")
1714
+
1715
+ dn_invert = gr.Checkbox(
1716
+ label="Invert Image (Create Negative)",
1717
+ value=True,
1718
+ )
1719
+
1720
+ dn_color_mode = gr.Dropdown(
1721
+ choices=[
1722
+ ("Grayscale", "grayscale"),
1723
+ ("Preserve Original", "preserve"),
1724
+ ("RGB", "rgb"),
1725
+ ],
1726
+ value="grayscale",
1727
+ label="Color Mode",
1728
+ )
1729
+
1730
+ process_dn_btn = gr.Button("Create Digital Negative", variant="primary")
1731
+
1732
+ with gr.Column(scale=2):
1733
+ gr.Markdown("#### Result")
1734
+
1735
+ dn_result_image = gr.Image(
1736
+ label="Digital Negative Preview",
1737
+ interactive=False,
1738
+ )
1739
+
1740
+ dn_info_display = gr.JSON(label="Processing Info")
1741
+
1742
+ gr.Markdown("---")
1743
+ gr.Markdown("#### Export")
1744
+
1745
+ dn_export_format = gr.Dropdown(
1746
+ choices=[
1747
+ ("Same as Original", "original"),
1748
+ ("TIFF (Lossless)", "tiff"),
1749
+ ("TIFF 16-bit", "tiff_16bit"),
1750
+ ("PNG (Lossless)", "png"),
1751
+ ("PNG 16-bit", "png_16bit"),
1752
+ ("JPEG (Standard)", "jpeg"),
1753
+ ("JPEG (High Quality)", "jpeg_high"),
1754
+ ],
1755
+ value="original",
1756
+ label="Export Format",
1757
+ )
1758
+
1759
+ dn_jpeg_quality = gr.Slider(
1760
+ minimum=50,
1761
+ maximum=100,
1762
+ value=95,
1763
+ step=5,
1764
+ label="JPEG Quality (if applicable)",
1765
+ )
1766
+
1767
+ export_dn_btn = gr.Button("Export Digital Negative", variant="secondary")
1768
+ dn_export_file = gr.File(label="Download")
1769
+
1770
+ def load_dn_curve(file):
1771
+ """Load curve for digital negative."""
1772
+ if file is None:
1773
+ return None, {"status": "No curve (will invert only)"}
1774
+
1775
+ try:
1776
+ file_path = Path(file.name)
1777
+ suffix = file_path.suffix.lower()
1778
+
1779
+ if suffix in [".quad", ".txt"]:
1780
+ profile = load_quad_file(file_path)
1781
+ curve = profile.to_curve_data("K")
1782
+ elif suffix == ".json":
1783
+ from ptpd_calibration.curves.export import load_curve
1784
+ curve = load_curve(file_path)
1785
+ elif suffix == ".csv":
1786
+ from ptpd_calibration.curves.export import load_curve
1787
+ curve = load_curve(file_path)
1788
+ else:
1789
+ return None, {"error": f"Unsupported: {suffix}"}
1790
+
1791
+ return curve, {
1792
+ "name": curve.name,
1793
+ "points": len(curve.input_values),
1794
+ }
1795
+ except Exception as e:
1796
+ return None, {"error": str(e)}
1797
+
1798
+ def load_dn_custom_values(values_str, name):
1799
+ """Load custom curve values."""
1800
+ if not values_str.strip():
1801
+ return None, {"status": "No curve (will invert only)"}
1802
+
1803
+ try:
1804
+ values = [float(v.strip()) for v in values_str.split(",")]
1805
+ inputs = [i / (len(values) - 1) for i in range(len(values))]
1806
+
1807
+ curve = CurveData(
1808
+ name=name or "Custom Curve",
1809
+ input_values=inputs,
1810
+ output_values=values,
1811
+ )
1812
+
1813
+ return curve, {
1814
+ "name": curve.name,
1815
+ "points": len(curve.input_values),
1816
+ }
1817
+ except Exception as e:
1818
+ return None, {"error": str(e)}
1819
+
1820
+ def create_digital_negative(image_path, curve, invert, color_mode_str):
1821
+ """Create digital negative from image."""
1822
+ if image_path is None:
1823
+ return None, None, {"error": "No image uploaded"}
1824
+
1825
+ try:
1826
+ processor = ImageProcessor()
1827
+ color_mode = ColorMode(color_mode_str)
1828
+
1829
+ result = processor.create_digital_negative(
1830
+ image_path,
1831
+ curve=curve,
1832
+ invert=invert,
1833
+ color_mode=color_mode,
1834
+ )
1835
+
1836
+ return result, result.image, result.get_info()
1837
+ except Exception as e:
1838
+ return None, None, {"error": str(e)}
1839
+
1840
+ def export_digital_negative(result, export_format, jpeg_quality):
1841
+ """Export the digital negative."""
1842
+ if result is None:
1843
+ return None
1844
+
1845
+ try:
1846
+ import tempfile
1847
+ processor = ImageProcessor()
1848
+
1849
+ settings = ExportSettings(
1850
+ format=ImageFormat(export_format),
1851
+ jpeg_quality=int(jpeg_quality),
1852
+ )
1853
+
1854
+ # Determine extension
1855
+ ext_map = {
1856
+ "original": result.original_format or "png",
1857
+ "tiff": "tiff",
1858
+ "tiff_16bit": "tiff",
1859
+ "png": "png",
1860
+ "png_16bit": "png",
1861
+ "jpeg": "jpg",
1862
+ "jpeg_high": "jpg",
1863
+ }
1864
+ ext = ext_map.get(export_format, "png")
1865
+
1866
+ temp_path = Path(tempfile.gettempdir()) / f"digital_negative.{ext}"
1867
+ processor.export(result, temp_path, settings)
1868
+
1869
+ return str(temp_path)
1870
+ except Exception as e:
1871
+ return None
1872
+
1873
+ # Connect handlers
1874
+ load_dn_curve_btn.click(
1875
+ load_dn_curve,
1876
+ inputs=[dn_curve_file],
1877
+ outputs=[dn_curve_state, dn_curve_info],
1878
+ )
1879
+
1880
+ load_dn_custom_btn.click(
1881
+ load_dn_custom_values,
1882
+ inputs=[dn_curve_values, dn_curve_name_input],
1883
+ outputs=[dn_curve_state, dn_curve_info],
1884
+ )
1885
+
1886
+ process_dn_btn.click(
1887
+ create_digital_negative,
1888
+ inputs=[dn_image_upload, dn_curve_state, dn_invert, dn_color_mode],
1889
+ outputs=[dn_result_state, dn_result_image, dn_info_display],
1890
+ )
1891
+
1892
+ export_dn_btn.click(
1893
+ export_digital_negative,
1894
+ inputs=[dn_result_state, dn_export_format, dn_jpeg_quality],
1895
+ outputs=[dn_export_file],
1896
+ )
1897
+
1898
+ # ========================================
1899
+ # TAB 10: Interactive Curve Editor
1900
+ # ========================================
1901
+ with gr.TabItem("Interactive Editor"):
1902
+ gr.Markdown(
1903
+ """
1904
+ ### Interactive Curve Editor
1905
+
1906
+ Create and edit calibration curves by adjusting control points numerically
1907
+ or using preset adjustments. Changes update the curve visualization in real-time.
1908
+ """
1909
+ )
1910
+
1911
+ # State for interactive curve
1912
+ ie_curve_state = gr.State({
1913
+ "points": [(0.0, 0.0), (0.25, 0.25), (0.5, 0.5), (0.75, 0.75), (1.0, 1.0)],
1914
+ "name": "Custom Curve",
1915
+ })
1916
+
1917
+ with gr.Row():
1918
+ with gr.Column(scale=1):
1919
+ gr.Markdown("#### Control Points")
1920
+ gr.Markdown("*Adjust input/output pairs (0-1 range)*")
1921
+
1922
+ # Create 9 editable control points
1923
+ ie_point_inputs = []
1924
+ ie_point_outputs = []
1925
+
1926
+ with gr.Accordion("Control Points (0-1)", open=True):
1927
+ for i in range(9):
1928
+ with gr.Row():
1929
+ inp = gr.Number(
1930
+ label=f"In {i+1}",
1931
+ value=i / 8.0,
1932
+ minimum=0.0,
1933
+ maximum=1.0,
1934
+ step=0.01,
1935
+ scale=1,
1936
+ )
1937
+ out = gr.Number(
1938
+ label=f"Out {i+1}",
1939
+ value=i / 8.0,
1940
+ minimum=0.0,
1941
+ maximum=1.0,
1942
+ step=0.01,
1943
+ scale=1,
1944
+ )
1945
+ ie_point_inputs.append(inp)
1946
+ ie_point_outputs.append(out)
1947
+
1948
+ update_ie_curve_btn = gr.Button("Update Curve", variant="primary")
1949
+
1950
+ gr.Markdown("---")
1951
+ gr.Markdown("#### Presets")
1952
+
1953
+ ie_preset_select = gr.Dropdown(
1954
+ choices=[
1955
+ ("Linear (No Change)", "linear"),
1956
+ ("S-Curve (Contrast)", "s_curve"),
1957
+ ("Brighten Highlights", "brighten"),
1958
+ ("Darken Shadows", "darken"),
1959
+ ("High Contrast", "high_contrast"),
1960
+ ("Low Contrast", "low_contrast"),
1961
+ ("Gamma 1.8", "gamma_18"),
1962
+ ("Gamma 2.2", "gamma_22"),
1963
+ ],
1964
+ label="Load Preset",
1965
+ )
1966
+ apply_preset_btn = gr.Button("Apply Preset")
1967
+
1968
+ gr.Markdown("---")
1969
+ gr.Markdown("#### Quick Adjustments")
1970
+
1971
+ ie_gamma_slider = gr.Slider(
1972
+ minimum=0.5,
1973
+ maximum=3.0,
1974
+ value=1.0,
1975
+ step=0.1,
1976
+ label="Gamma",
1977
+ )
1978
+ apply_gamma_btn = gr.Button("Apply Gamma")
1979
+
1980
+ ie_curve_name = gr.Textbox(
1981
+ label="Curve Name",
1982
+ value="Custom Curve",
1983
+ )
1984
+
1985
+ with gr.Column(scale=2):
1986
+ gr.Markdown("#### Curve Visualization")
1987
+ ie_curve_plot = gr.Plot(label="Interactive Curve")
1988
+
1989
+ ie_curve_info = gr.JSON(label="Curve Data")
1990
+
1991
+ gr.Markdown("---")
1992
+ gr.Markdown("#### Export")
1993
+
1994
+ ie_export_format = gr.Dropdown(
1995
+ choices=["qtr", "csv", "json"],
1996
+ value="qtr",
1997
+ label="Export Format",
1998
+ )
1999
+ export_ie_btn = gr.Button("Export Curve")
2000
+ ie_export_file = gr.File(label="Download")
2001
+
2002
+ def update_ie_curve_from_points(*args):
2003
+ """Update curve from control point values."""
2004
+ import matplotlib.pyplot as plt
2005
+
2006
+ # First 9 args are inputs, next 9 are outputs
2007
+ inputs = list(args[:9])
2008
+ outputs = list(args[9:18])
2009
+
2010
+ # Filter out None values and create valid points
2011
+ points = []
2012
+ for inp, out in zip(inputs, outputs):
2013
+ if inp is not None and out is not None:
2014
+ points.append((float(inp), float(out)))
2015
+
2016
+ # Sort by input value
2017
+ points.sort(key=lambda x: x[0])
2018
+
2019
+ if len(points) < 2:
2020
+ return None, {"error": "Need at least 2 points"}
2021
+
2022
+ # Create curve data
2023
+ curve = CurveData(
2024
+ name="Interactive Curve",
2025
+ input_values=[p[0] for p in points],
2026
+ output_values=[p[1] for p in points],
2027
+ )
2028
+
2029
+ # Create plot
2030
+ fig, ax = plt.subplots(figsize=(8, 6))
2031
+ ax.plot(curve.input_values, curve.output_values, "o-", color="#8B4513", linewidth=2, markersize=8, label="Curve")
2032
+ ax.plot([0, 1], [0, 1], "--", color="gray", alpha=0.5, label="Linear")
2033
+ ax.set_xlabel("Input")
2034
+ ax.set_ylabel("Output")
2035
+ ax.set_title("Interactive Curve Editor")
2036
+ ax.legend()
2037
+ ax.grid(True, alpha=0.3)
2038
+ ax.set_xlim(0, 1)
2039
+ ax.set_ylim(0, 1)
2040
+ ax.set_facecolor("#FAF8F5")
2041
+ fig.patch.set_facecolor("#FAF8F5")
2042
+
2043
+ info = {
2044
+ "points": len(points),
2045
+ "input_range": f"{min(curve.input_values):.3f} - {max(curve.input_values):.3f}",
2046
+ "output_range": f"{min(curve.output_values):.3f} - {max(curve.output_values):.3f}",
2047
+ }
2048
+
2049
+ return fig, info
2050
+
2051
+ def apply_ie_preset(preset):
2052
+ """Apply a preset to the curve."""
2053
+ presets = {
2054
+ "linear": [(i/8, i/8) for i in range(9)],
2055
+ "s_curve": [(0, 0), (0.125, 0.08), (0.25, 0.18), (0.375, 0.35),
2056
+ (0.5, 0.5), (0.625, 0.65), (0.75, 0.82), (0.875, 0.92), (1, 1)],
2057
+ "brighten": [(i/8, min(1, (i/8) * 1.2 + 0.05)) for i in range(9)],
2058
+ "darken": [(i/8, max(0, (i/8) * 0.8)) for i in range(9)],
2059
+ "high_contrast": [(0, 0), (0.125, 0.03), (0.25, 0.1), (0.375, 0.25),
2060
+ (0.5, 0.5), (0.625, 0.75), (0.75, 0.9), (0.875, 0.97), (1, 1)],
2061
+ "low_contrast": [(0, 0.1), (0.125, 0.175), (0.25, 0.275), (0.375, 0.375),
2062
+ (0.5, 0.5), (0.625, 0.625), (0.75, 0.725), (0.875, 0.825), (1, 0.9)],
2063
+ "gamma_18": [(i/8, (i/8) ** (1/1.8)) for i in range(9)],
2064
+ "gamma_22": [(i/8, (i/8) ** (1/2.2)) for i in range(9)],
2065
+ }
2066
+
2067
+ points = presets.get(preset, presets["linear"])
2068
+
2069
+ # Return values for all 9 input/output pairs
2070
+ results = []
2071
+ for i in range(9):
2072
+ if i < len(points):
2073
+ results.append(points[i][0]) # input
2074
+ else:
2075
+ results.append(i / 8.0)
2076
+
2077
+ for i in range(9):
2078
+ if i < len(points):
2079
+ results.append(points[i][1]) # output
2080
+ else:
2081
+ results.append(i / 8.0)
2082
+
2083
+ return results
2084
+
2085
+ def apply_gamma_to_curve(gamma, *current_values):
2086
+ """Apply gamma adjustment to current curve."""
2087
+ outputs = list(current_values[9:18])
2088
+
2089
+ # Apply gamma to outputs
2090
+ new_outputs = []
2091
+ for i, out in enumerate(outputs):
2092
+ if out is not None:
2093
+ inp = i / 8.0
2094
+ # Apply gamma: output = input^(1/gamma)
2095
+ new_out = inp ** (1 / gamma) if gamma > 0 else inp
2096
+ new_outputs.append(min(1.0, max(0.0, new_out)))
2097
+ else:
2098
+ new_outputs.append(i / 8.0)
2099
+
2100
+ return new_outputs
2101
+
2102
+ def export_ie_curve(export_format, name, *point_values):
2103
+ """Export the interactive curve."""
2104
+ try:
2105
+ import tempfile
2106
+
2107
+ inputs = list(point_values[:9])
2108
+ outputs = list(point_values[9:18])
2109
+
2110
+ points = []
2111
+ for inp, out in zip(inputs, outputs):
2112
+ if inp is not None and out is not None:
2113
+ points.append((float(inp), float(out)))
2114
+
2115
+ points.sort(key=lambda x: x[0])
2116
+
2117
+ curve = CurveData(
2118
+ name=name or "Interactive Curve",
2119
+ input_values=[p[0] for p in points],
2120
+ output_values=[p[1] for p in points],
2121
+ )
2122
+
2123
+ ext_map = {"qtr": ".txt", "csv": ".csv", "json": ".json"}
2124
+ ext = ext_map.get(export_format, ".txt")
2125
+
2126
+ safe_name = "".join(c for c in curve.name if c.isalnum() or c in " -_")[:30]
2127
+ temp_path = Path(tempfile.gettempdir()) / f"{safe_name}{ext}"
2128
+
2129
+ save_curve(curve, temp_path, format=export_format)
2130
+
2131
+ return str(temp_path)
2132
+ except Exception:
2133
+ return None
2134
+
2135
+ # Connect handlers
2136
+ all_point_components = ie_point_inputs + ie_point_outputs
2137
+
2138
+ update_ie_curve_btn.click(
2139
+ update_ie_curve_from_points,
2140
+ inputs=all_point_components,
2141
+ outputs=[ie_curve_plot, ie_curve_info],
2142
+ )
2143
+
2144
+ apply_preset_btn.click(
2145
+ apply_ie_preset,
2146
+ inputs=[ie_preset_select],
2147
+ outputs=ie_point_inputs + ie_point_outputs,
2148
+ ).then(
2149
+ update_ie_curve_from_points,
2150
+ inputs=all_point_components,
2151
+ outputs=[ie_curve_plot, ie_curve_info],
2152
+ )
2153
+
2154
+ apply_gamma_btn.click(
2155
+ apply_gamma_to_curve,
2156
+ inputs=[ie_gamma_slider] + all_point_components,
2157
+ outputs=ie_point_outputs,
2158
+ ).then(
2159
+ update_ie_curve_from_points,
2160
+ inputs=all_point_components,
2161
+ outputs=[ie_curve_plot, ie_curve_info],
2162
+ )
2163
+
2164
+ export_ie_btn.click(
2165
+ export_ie_curve,
2166
+ inputs=[ie_export_format, ie_curve_name] + all_point_components,
2167
+ outputs=[ie_export_file],
2168
+ )
2169
+
2170
+ # ========================================
2171
+ # TAB 11: Chemistry Calculator
2172
  # ========================================
2173
  with gr.TabItem("Chemistry Calculator"):
2174
  gr.Markdown(
 
2366
  )
2367
 
2368
  # ========================================
2369
+ # TAB 12: Settings
2370
  # ========================================
2371
  with gr.TabItem("Settings"):
2372
  gr.Markdown(
 
2541
  )
2542
 
2543
  # ========================================
2544
+ # TAB 13: About
2545
  # ========================================
2546
  with gr.TabItem("About"):
2547
  gr.Markdown(
 
2559
  - **Curve Editor**: Upload .quad files, modify curves, smooth curves, and apply AI-powered enhancements
2560
  - **AI Enhancement**: Intelligent curve optimization
2561
  - **AI Assistant**: Get help from an AI expert in Pt/Pd printing
2562
+ - **Image Preview**: Upload an image, select a curve, and preview the output
2563
+ - **Digital Negative**: Create inverted negatives with curves applied, export in multiple formats
2564
+ - **Interactive Editor**: Create curves with numeric control points and presets
2565
  - **Chemistry Calculator**: Calculate coating solution amounts for any print size
2566
  - **Recipe Suggestions**: Get starting parameters for new papers
2567
  - **Troubleshooting**: Diagnose and fix common problems
 
2573
  - CSV
2574
  - JSON
2575
 
2576
+ ### Image Export Formats
2577
+
2578
+ - TIFF (8-bit and 16-bit)
2579
+ - PNG (8-bit and 16-bit)
2580
+ - JPEG (standard and high quality)
2581
+ - Original format preservation
2582
+
2583
  ### Chemistry Reference
2584
 
2585
  Based on [Bostick-Sullivan Platinum/Palladium Kit Instructions](https://www.bostick-sullivan.com/wp-content/uploads/2022/03/platinum-and-palladium-kit-instructions.pdf):
tests/unit/test_image_processor.py ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for image processor module.
3
+
4
+ Tests curve application, inversion, and export functionality.
5
+ """
6
+
7
+ import io
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import pytest
13
+ from PIL import Image
14
+
15
+ from ptpd_calibration.core.models import CurveData
16
+ from ptpd_calibration.imaging import (
17
+ ImageProcessor,
18
+ ImageFormat,
19
+ ExportSettings,
20
+ ProcessingResult,
21
+ )
22
+ from ptpd_calibration.imaging.processor import ColorMode
23
+
24
+
25
+ class TestImageFormat:
26
+ """Tests for ImageFormat enum."""
27
+
28
+ def test_all_formats_defined(self):
29
+ """All expected formats should be defined."""
30
+ assert ImageFormat.TIFF
31
+ assert ImageFormat.TIFF_16BIT
32
+ assert ImageFormat.PNG
33
+ assert ImageFormat.PNG_16BIT
34
+ assert ImageFormat.JPEG
35
+ assert ImageFormat.JPEG_HIGH
36
+ assert ImageFormat.ORIGINAL
37
+
38
+ def test_format_values(self):
39
+ """Format values should be valid strings."""
40
+ assert ImageFormat.TIFF.value == "tiff"
41
+ assert ImageFormat.PNG.value == "png"
42
+ assert ImageFormat.JPEG.value == "jpeg"
43
+ assert ImageFormat.ORIGINAL.value == "original"
44
+
45
+
46
+ class TestColorMode:
47
+ """Tests for ColorMode enum."""
48
+
49
+ def test_color_mode_values(self):
50
+ """Color mode values should be valid."""
51
+ assert ColorMode.GRAYSCALE.value == "grayscale"
52
+ assert ColorMode.RGB.value == "rgb"
53
+ assert ColorMode.PRESERVE.value == "preserve"
54
+
55
+
56
+ class TestExportSettings:
57
+ """Tests for ExportSettings dataclass."""
58
+
59
+ def test_default_settings(self):
60
+ """Default settings should be sensible."""
61
+ settings = ExportSettings()
62
+ assert settings.format == ImageFormat.ORIGINAL
63
+ assert settings.jpeg_quality == 95
64
+ assert settings.preserve_metadata is True
65
+ assert settings.preserve_resolution is True
66
+
67
+ def test_custom_settings(self):
68
+ """Custom settings should be applied."""
69
+ settings = ExportSettings(
70
+ format=ImageFormat.JPEG_HIGH,
71
+ jpeg_quality=100,
72
+ preserve_metadata=False,
73
+ target_dpi=300,
74
+ )
75
+ assert settings.format == ImageFormat.JPEG_HIGH
76
+ assert settings.jpeg_quality == 100
77
+ assert settings.preserve_metadata is False
78
+ assert settings.target_dpi == 300
79
+
80
+
81
+ class TestProcessingResult:
82
+ """Tests for ProcessingResult dataclass."""
83
+
84
+ @pytest.fixture
85
+ def sample_result(self):
86
+ """Create a sample processing result."""
87
+ img = Image.new("RGB", (100, 100), color=(128, 128, 128))
88
+ return ProcessingResult(
89
+ image=img,
90
+ original_size=(100, 100),
91
+ original_mode="RGB",
92
+ original_format="PNG",
93
+ original_dpi=(300, 300),
94
+ curve_applied=True,
95
+ inverted=False,
96
+ processing_notes=["Test note"],
97
+ )
98
+
99
+ def test_get_info(self, sample_result):
100
+ """get_info should return valid dictionary."""
101
+ info = sample_result.get_info()
102
+ assert "size" in info
103
+ assert "original_size" in info
104
+ assert "mode" in info
105
+ assert "curve_applied" in info
106
+ assert "inverted" in info
107
+ assert "notes" in info
108
+
109
+ def test_info_values(self, sample_result):
110
+ """Info values should match result."""
111
+ info = sample_result.get_info()
112
+ assert info["size"] == "100x100"
113
+ assert info["original_mode"] == "RGB"
114
+ assert info["curve_applied"] is True
115
+ assert info["inverted"] is False
116
+
117
+
118
+ class TestImageProcessor:
119
+ """Tests for ImageProcessor class."""
120
+
121
+ @pytest.fixture
122
+ def processor(self):
123
+ """Create image processor."""
124
+ return ImageProcessor()
125
+
126
+ @pytest.fixture
127
+ def grayscale_image(self):
128
+ """Create a grayscale test image."""
129
+ arr = np.zeros((100, 100), dtype=np.uint8)
130
+ # Create gradient
131
+ for i in range(100):
132
+ arr[i, :] = int(i * 2.55)
133
+ return Image.fromarray(arr, mode="L")
134
+
135
+ @pytest.fixture
136
+ def rgb_image(self):
137
+ """Create an RGB test image."""
138
+ arr = np.zeros((100, 100, 3), dtype=np.uint8)
139
+ # Create color gradient
140
+ for i in range(100):
141
+ arr[i, :, 0] = int(i * 2.55) # Red
142
+ arr[i, :, 1] = int((100 - i) * 2.55) # Green
143
+ arr[i, :, 2] = 128 # Blue
144
+ return Image.fromarray(arr, mode="RGB")
145
+
146
+ @pytest.fixture
147
+ def linear_curve(self):
148
+ """Create a linear curve (no change)."""
149
+ return CurveData(
150
+ name="Linear",
151
+ input_values=[i / 10 for i in range(11)],
152
+ output_values=[i / 10 for i in range(11)],
153
+ )
154
+
155
+ @pytest.fixture
156
+ def contrast_curve(self):
157
+ """Create an S-curve for contrast."""
158
+ inputs = [i / 10 for i in range(11)]
159
+ # S-curve formula
160
+ outputs = [0.5 + 0.5 * np.tanh(2 * (x - 0.5)) for x in inputs]
161
+ outputs = [(o - min(outputs)) / (max(outputs) - min(outputs)) for o in outputs]
162
+ return CurveData(
163
+ name="Contrast",
164
+ input_values=inputs,
165
+ output_values=outputs,
166
+ )
167
+
168
+ def test_load_image_from_pil(self, processor, grayscale_image):
169
+ """Load image from PIL Image."""
170
+ result = processor.load_image(grayscale_image)
171
+ assert result.image is not None
172
+ assert result.original_size == (100, 100)
173
+ assert result.original_mode == "L"
174
+ assert result.curve_applied is False
175
+ assert result.inverted is False
176
+
177
+ def test_load_image_from_numpy(self, processor):
178
+ """Load image from numpy array."""
179
+ arr = np.ones((50, 50), dtype=np.uint8) * 128
180
+ result = processor.load_image(arr)
181
+ assert result.image is not None
182
+ assert result.original_size == (50, 50)
183
+ assert result.original_mode == "L"
184
+
185
+ def test_load_image_rgb_from_numpy(self, processor):
186
+ """Load RGB image from numpy array."""
187
+ arr = np.ones((50, 50, 3), dtype=np.uint8) * 128
188
+ result = processor.load_image(arr)
189
+ assert result.image is not None
190
+ assert result.original_mode == "RGB"
191
+
192
+ def test_load_image_from_bytes(self, processor, grayscale_image):
193
+ """Load image from bytes."""
194
+ buffer = io.BytesIO()
195
+ grayscale_image.save(buffer, format="PNG")
196
+ buffer.seek(0)
197
+
198
+ result = processor.load_image(buffer.getvalue())
199
+ assert result.image is not None
200
+ assert result.original_size == (100, 100)
201
+
202
+ def test_apply_linear_curve_no_change(self, processor, grayscale_image, linear_curve):
203
+ """Linear curve should not change image significantly."""
204
+ result = processor.load_image(grayscale_image)
205
+ processed = processor.apply_curve(result, linear_curve)
206
+
207
+ assert processed.curve_applied is True
208
+ assert processed.image.size == grayscale_image.size
209
+
210
+ # Values should be approximately the same
211
+ orig_arr = np.array(grayscale_image)
212
+ proc_arr = np.array(processed.image)
213
+ assert np.allclose(orig_arr, proc_arr, atol=2)
214
+
215
+ def test_apply_contrast_curve(self, processor, grayscale_image, contrast_curve):
216
+ """Contrast curve should modify image."""
217
+ result = processor.load_image(grayscale_image)
218
+ processed = processor.apply_curve(result, contrast_curve)
219
+
220
+ assert processed.curve_applied is True
221
+
222
+ # Midtones should be preserved, but darks darker and lights lighter
223
+ orig_arr = np.array(grayscale_image)
224
+ proc_arr = np.array(processed.image)
225
+
226
+ # Not exactly the same
227
+ assert not np.allclose(orig_arr, proc_arr, atol=5)
228
+
229
+ def test_apply_curve_rgb(self, processor, rgb_image, linear_curve):
230
+ """Curve should apply to RGB image."""
231
+ result = processor.load_image(rgb_image)
232
+ processed = processor.apply_curve(result, linear_curve, ColorMode.RGB)
233
+
234
+ assert processed.curve_applied is True
235
+ assert processed.image.mode == "RGB"
236
+
237
+ def test_apply_curve_grayscale_conversion(self, processor, rgb_image, linear_curve):
238
+ """RGB image should convert to grayscale when requested."""
239
+ result = processor.load_image(rgb_image)
240
+ processed = processor.apply_curve(result, linear_curve, ColorMode.GRAYSCALE)
241
+
242
+ assert processed.image.mode == "L"
243
+
244
+ def test_invert_grayscale(self, processor, grayscale_image):
245
+ """Inverting grayscale should flip values."""
246
+ result = processor.load_image(grayscale_image)
247
+ inverted = processor.invert(result)
248
+
249
+ assert inverted.inverted is True
250
+
251
+ orig_arr = np.array(grayscale_image)
252
+ inv_arr = np.array(inverted.image)
253
+
254
+ # Check inversion
255
+ assert np.allclose(inv_arr, 255 - orig_arr)
256
+
257
+ def test_invert_rgb(self, processor, rgb_image):
258
+ """Inverting RGB should flip all channels."""
259
+ result = processor.load_image(rgb_image)
260
+ inverted = processor.invert(result)
261
+
262
+ assert inverted.inverted is True
263
+
264
+ orig_arr = np.array(rgb_image)
265
+ inv_arr = np.array(inverted.image)
266
+
267
+ assert np.allclose(inv_arr, 255 - orig_arr)
268
+
269
+ def test_double_invert_restores_original(self, processor, grayscale_image):
270
+ """Double inversion should restore original."""
271
+ result = processor.load_image(grayscale_image)
272
+ inverted1 = processor.invert(result)
273
+ inverted2 = processor.invert(inverted1)
274
+
275
+ # inverted twice = not inverted
276
+ assert inverted2.inverted is False
277
+
278
+ orig_arr = np.array(grayscale_image)
279
+ double_arr = np.array(inverted2.image)
280
+ assert np.allclose(orig_arr, double_arr)
281
+
282
+ def test_create_digital_negative(self, processor, grayscale_image, linear_curve):
283
+ """Create complete digital negative."""
284
+ result = processor.create_digital_negative(
285
+ grayscale_image,
286
+ curve=linear_curve,
287
+ invert=True,
288
+ color_mode=ColorMode.GRAYSCALE,
289
+ )
290
+
291
+ assert result.curve_applied is True
292
+ assert result.inverted is True
293
+ assert result.image.mode == "L"
294
+
295
+ def test_create_digital_negative_no_curve(self, processor, grayscale_image):
296
+ """Digital negative without curve (invert only)."""
297
+ result = processor.create_digital_negative(
298
+ grayscale_image,
299
+ curve=None,
300
+ invert=True,
301
+ )
302
+
303
+ assert result.curve_applied is False
304
+ assert result.inverted is True
305
+
306
+ def test_create_digital_negative_no_invert(self, processor, grayscale_image, linear_curve):
307
+ """Digital negative with curve but no inversion."""
308
+ result = processor.create_digital_negative(
309
+ grayscale_image,
310
+ curve=linear_curve,
311
+ invert=False,
312
+ )
313
+
314
+ assert result.curve_applied is True
315
+ assert result.inverted is False
316
+
317
+ def test_preview_curve_effect(self, processor, grayscale_image, linear_curve):
318
+ """Preview should return both original and processed images."""
319
+ original, processed = processor.preview_curve_effect(
320
+ grayscale_image,
321
+ linear_curve,
322
+ )
323
+
324
+ assert original is not None
325
+ assert processed is not None
326
+ assert original.size == processed.size
327
+
328
+ def test_preview_with_thumbnail(self, processor, grayscale_image, linear_curve):
329
+ """Preview with thumbnail size should resize."""
330
+ original, processed = processor.preview_curve_effect(
331
+ grayscale_image,
332
+ linear_curve,
333
+ thumbnail_size=(50, 50),
334
+ )
335
+
336
+ assert max(original.size) <= 50
337
+ assert max(processed.size) <= 50
338
+
339
+ def test_export_to_file_png(self, processor, grayscale_image):
340
+ """Export to PNG file."""
341
+ result = processor.load_image(grayscale_image)
342
+
343
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
344
+ output_path = Path(f.name)
345
+
346
+ try:
347
+ settings = ExportSettings(format=ImageFormat.PNG)
348
+ processor.export(result, output_path, settings)
349
+
350
+ assert output_path.exists()
351
+ # Verify it's a valid image
352
+ loaded = Image.open(output_path)
353
+ assert loaded.size == grayscale_image.size
354
+ finally:
355
+ output_path.unlink(missing_ok=True)
356
+
357
+ def test_export_to_file_jpeg(self, processor, rgb_image):
358
+ """Export to JPEG file."""
359
+ result = processor.load_image(rgb_image)
360
+
361
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
362
+ output_path = Path(f.name)
363
+
364
+ try:
365
+ settings = ExportSettings(format=ImageFormat.JPEG, jpeg_quality=90)
366
+ processor.export(result, output_path, settings)
367
+
368
+ assert output_path.exists()
369
+ loaded = Image.open(output_path)
370
+ assert loaded.size == rgb_image.size
371
+ finally:
372
+ output_path.unlink(missing_ok=True)
373
+
374
+ def test_export_to_file_tiff(self, processor, grayscale_image):
375
+ """Export to TIFF file."""
376
+ result = processor.load_image(grayscale_image)
377
+
378
+ with tempfile.NamedTemporaryFile(suffix=".tiff", delete=False) as f:
379
+ output_path = Path(f.name)
380
+
381
+ try:
382
+ settings = ExportSettings(format=ImageFormat.TIFF)
383
+ processor.export(result, output_path, settings)
384
+
385
+ assert output_path.exists()
386
+ finally:
387
+ output_path.unlink(missing_ok=True)
388
+
389
+ def test_export_to_bytes(self, processor, grayscale_image):
390
+ """Export to bytes."""
391
+ result = processor.load_image(grayscale_image)
392
+ settings = ExportSettings(format=ImageFormat.PNG)
393
+
394
+ data, ext = processor.export_to_bytes(result, settings)
395
+
396
+ assert data is not None
397
+ assert len(data) > 0
398
+ assert ext == ".png"
399
+
400
+ # Verify it's a valid image
401
+ img = Image.open(io.BytesIO(data))
402
+ assert img.size == grayscale_image.size
403
+
404
+ def test_export_jpeg_quality(self, processor, rgb_image):
405
+ """Higher JPEG quality should produce larger files."""
406
+ result = processor.load_image(rgb_image)
407
+
408
+ low_quality = ExportSettings(format=ImageFormat.JPEG, jpeg_quality=50)
409
+ high_quality = ExportSettings(format=ImageFormat.JPEG, jpeg_quality=100)
410
+
411
+ low_data, _ = processor.export_to_bytes(result, low_quality)
412
+ high_data, _ = processor.export_to_bytes(result, high_quality)
413
+
414
+ # High quality should be larger
415
+ assert len(high_data) > len(low_data)
416
+
417
+ def test_get_supported_formats(self):
418
+ """Supported formats should include common types."""
419
+ formats = ImageProcessor.get_supported_formats()
420
+ assert ".jpg" in formats
421
+ assert ".png" in formats
422
+ assert ".tiff" in formats
423
+
424
+ def test_get_export_formats(self):
425
+ """Export formats should be available."""
426
+ formats = ImageProcessor.get_export_formats()
427
+ assert len(formats) > 0
428
+ # Each format should be (value, description) tuple
429
+ for value, desc in formats:
430
+ assert isinstance(value, str)
431
+ assert isinstance(desc, str)
432
+
433
+
434
+ class TestCurveLUT:
435
+ """Tests for LUT creation and application."""
436
+
437
+ @pytest.fixture
438
+ def processor(self):
439
+ return ImageProcessor()
440
+
441
+ def test_lut_creates_256_entries(self, processor):
442
+ """LUT should have 256 entries."""
443
+ curve = CurveData(
444
+ name="Test",
445
+ input_values=[0, 0.5, 1],
446
+ output_values=[0, 0.5, 1],
447
+ )
448
+ lut = processor._create_lut(curve)
449
+ assert len(lut) == 256
450
+
451
+ def test_lut_interpolates(self, processor):
452
+ """LUT should interpolate between curve points."""
453
+ curve = CurveData(
454
+ name="Test",
455
+ input_values=[0, 1],
456
+ output_values=[0, 1],
457
+ )
458
+ lut = processor._create_lut(curve)
459
+
460
+ # Check midpoint interpolation
461
+ assert lut[127] in range(125, 130)
462
+ assert lut[0] == 0
463
+ assert lut[255] == 255
464
+
465
+ def test_lut_caches(self, processor):
466
+ """LUT should be cached for repeated use."""
467
+ curve = CurveData(
468
+ name="CachedCurve",
469
+ input_values=[0, 1],
470
+ output_values=[0, 1],
471
+ )
472
+
473
+ lut1 = processor._create_lut(curve)
474
+ lut2 = processor._create_lut(curve)
475
+
476
+ # Should be the same object from cache
477
+ assert lut1 is lut2
478
+
479
+
480
+ class TestEdgeCases:
481
+ """Tests for edge cases."""
482
+
483
+ @pytest.fixture
484
+ def processor(self):
485
+ return ImageProcessor()
486
+
487
+ def test_load_unsupported_type_raises(self, processor):
488
+ """Loading unsupported type should raise."""
489
+ with pytest.raises(TypeError):
490
+ processor.load_image(12345)
491
+
492
+ def test_invert_rgba_preserves_alpha(self, processor):
493
+ """Inverting RGBA should preserve alpha channel."""
494
+ arr = np.ones((50, 50, 4), dtype=np.uint8) * 128
495
+ arr[:, :, 3] = 200 # Set alpha
496
+ img = Image.fromarray(arr, mode="RGBA")
497
+
498
+ result = processor.load_image(img)
499
+ inverted = processor.invert(result)
500
+
501
+ inv_arr = np.array(inverted.image)
502
+ # Alpha should be preserved
503
+ assert np.all(inv_arr[:, :, 3] == 200)
504
+ # RGB should be inverted
505
+ assert np.all(inv_arr[:, :, 0] == 127)
506
+
507
+ def test_curve_with_single_point_interpolates(self, processor):
508
+ """Curve with few points should still work."""
509
+ curve = CurveData(
510
+ name="Sparse",
511
+ input_values=[0, 1],
512
+ output_values=[0.2, 0.8],
513
+ )
514
+
515
+ arr = np.ones((10, 10), dtype=np.uint8) * 128
516
+ img = Image.fromarray(arr, mode="L")
517
+
518
+ result = processor.load_image(img)
519
+ processed = processor.apply_curve(result, curve)
520
+
521
+ assert processed.image is not None
522
+
523
+ def test_empty_processing_notes(self, processor):
524
+ """New result should have empty notes."""
525
+ arr = np.ones((10, 10), dtype=np.uint8) * 128
526
+ img = Image.fromarray(arr, mode="L")
527
+
528
+ result = processor.load_image(img)
529
+ assert result.processing_notes == []
530
+
531
+ def test_processing_notes_accumulate(self, processor):
532
+ """Notes should accumulate through processing."""
533
+ curve = CurveData(
534
+ name="Test",
535
+ input_values=[0, 1],
536
+ output_values=[0, 1],
537
+ )
538
+
539
+ arr = np.ones((10, 10), dtype=np.uint8) * 128
540
+ img = Image.fromarray(arr, mode="L")
541
+
542
+ result = processor.load_image(img)
543
+ result = processor.apply_curve(result, curve)
544
+ result = processor.invert(result)
545
+
546
+ assert len(result.processing_notes) == 2