Fioceen commited on
Commit
476915c
·
1 Parent(s): 8974103
analysis_panel.py CHANGED
@@ -1,74 +1,169 @@
1
  #!/usr/bin/env python3
2
  """
3
  Analysis panel for histogram, FFT, and radial profile plots.
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
- from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QSizePolicy
7
  from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
8
  from matplotlib.figure import Figure
9
  import numpy as np
10
  import os
 
11
  from utils import compute_gray_array, compute_fft_magnitude, radial_profile, make_canvas
12
 
 
13
  class AnalysisPanel(QWidget):
14
- def __init__(self, title="Analysis", parent=None):
15
  super().__init__(parent)
 
 
 
16
  v = QVBoxLayout(self)
17
  box = QGroupBox(title)
18
  vbox = QVBoxLayout()
19
  box.setLayout(vbox)
20
 
 
21
  row = QHBoxLayout()
 
 
22
  self.hist_canvas, self.hist_ax = make_canvas(width=3, height=2)
23
  self.fft_canvas, self.fft_ax = make_canvas(width=3, height=2)
24
  self.radial_canvas, self.radial_ax = make_canvas(width=3, height=2)
25
 
26
  for c in (self.hist_canvas, self.fft_canvas, self.radial_canvas):
27
  c.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
 
 
 
 
 
28
 
29
  row.addWidget(self.hist_canvas)
30
  row.addWidget(self.fft_canvas)
31
  row.addWidget(self.radial_canvas)
32
 
33
  vbox.addLayout(row)
 
 
 
 
 
 
 
34
  v.addWidget(box)
35
 
36
- def update_from_path(self, path):
 
 
 
 
 
37
  if not path or not os.path.exists(path):
 
 
38
  self.clear_plots()
39
  return
 
40
  try:
41
  gray = compute_gray_array(path)
42
- except Exception:
 
 
 
 
 
 
 
 
 
43
  self.clear_plots()
44
  return
45
 
46
- # Histogram
47
- self.hist_ax.cla()
48
- self.hist_ax.set_title('Grayscale histogram')
49
- self.hist_ax.set_xlabel('Intensity')
50
- self.hist_ax.set_ylabel('Count')
51
- self.hist_ax.hist(gray.ravel(), bins=256)
52
- self.hist_canvas.draw()
53
-
54
- # FFT magnitude
55
- mag, mag_log = compute_fft_magnitude(gray)
56
- self.fft_ax.cla()
57
- self.fft_ax.set_title('FFT magnitude (log)')
58
- self.fft_ax.imshow(mag_log, origin='lower', aspect='auto')
59
- self.fft_canvas.figure.subplots_adjust(right=0.85)
60
- self.fft_canvas.draw()
61
-
62
- # Radial profile
63
- centers, radial = radial_profile(mag)
64
- self.radial_ax.cla()
65
- self.radial_ax.set_title('Radial freq profile')
66
- self.radial_ax.set_xlabel('Normalized radius')
67
- self.radial_ax.set_ylabel('Mean magnitude')
68
- self.radial_ax.plot(centers, radial)
69
- self.radial_canvas.draw()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  def clear_plots(self):
 
72
  for ax, canvas in ((self.hist_ax, self.hist_canvas), (self.fft_ax, self.fft_canvas), (self.radial_ax, self.radial_canvas)):
73
- ax.cla()
74
- canvas.draw()
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
  Analysis panel for histogram, FFT, and radial profile plots.
4
+ Designed to plug straight into the provided run.py / MainWindow.
5
+
6
+ Exposes AnalysisPanel(title: str) with method update_from_path(path)
7
+ and clear_plots(). Uses helpers from utils:
8
+ - compute_gray_array(path) -> 2D numpy.ndarray (grayscale 0-255)
9
+ - compute_fft_magnitude(gray) -> (mag, mag_log)
10
+ - radial_profile(mag) -> (centers, radial)
11
+ - make_canvas(width, height) -> (FigureCanvas, Axes)
12
+
13
+ This module is intentionally defensive (catches errors) and keeps
14
+ its own layout compact so it will fit in the scrollable right-hand
15
+ panel in MainWindow.
16
  """
17
 
18
+ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QSizePolicy, QLabel
19
  from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
20
  from matplotlib.figure import Figure
21
  import numpy as np
22
  import os
23
+
24
  from utils import compute_gray_array, compute_fft_magnitude, radial_profile, make_canvas
25
 
26
+
27
  class AnalysisPanel(QWidget):
28
+ def __init__(self, title: str = "Analysis", parent=None):
29
  super().__init__(parent)
30
+ self.setMinimumHeight(220)
31
+
32
+ # Top-level layout + framed group
33
  v = QVBoxLayout(self)
34
  box = QGroupBox(title)
35
  vbox = QVBoxLayout()
36
  box.setLayout(vbox)
37
 
38
+ # Row of three matplotlib canvases
39
  row = QHBoxLayout()
40
+
41
+ # create canvases using project's make_canvas helper so styles match
42
  self.hist_canvas, self.hist_ax = make_canvas(width=3, height=2)
43
  self.fft_canvas, self.fft_ax = make_canvas(width=3, height=2)
44
  self.radial_canvas, self.radial_ax = make_canvas(width=3, height=2)
45
 
46
  for c in (self.hist_canvas, self.fft_canvas, self.radial_canvas):
47
  c.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
48
+ # give figures a consistent, compact margin so they sit well inside the GroupBox
49
+ try:
50
+ c.figure.subplots_adjust(top=0.88, bottom=0.12, left=0.12, right=0.96)
51
+ except Exception:
52
+ pass
53
 
54
  row.addWidget(self.hist_canvas)
55
  row.addWidget(self.fft_canvas)
56
  row.addWidget(self.radial_canvas)
57
 
58
  vbox.addLayout(row)
59
+
60
+ # small status label below canvases for quick diagnostics
61
+ self.status_label = QLabel("")
62
+ self.status_label.setWordWrap(True)
63
+ self.status_label.setVisible(False)
64
+ vbox.addWidget(self.status_label)
65
+
66
  v.addWidget(box)
67
 
68
+ def update_from_path(self, path: str):
69
+ """Update all three plots using the image at `path`.
70
+
71
+ If path is invalid or an error occurs while loading/processing,
72
+ plots are cleared and a status message is shown.
73
+ """
74
  if not path or not os.path.exists(path):
75
+ self.status_label.setText(f"No image: {path}")
76
+ self.status_label.setVisible(True)
77
  self.clear_plots()
78
  return
79
+
80
  try:
81
  gray = compute_gray_array(path)
82
+ if gray is None:
83
+ raise ValueError("compute_gray_array returned None")
84
+ # ensure grayscale array is 2D and finite
85
+ gray = np.asarray(gray)
86
+ if gray.ndim != 2:
87
+ raise ValueError("expected 2D grayscale array")
88
+
89
+ except Exception as e:
90
+ self.status_label.setText(f"Failed to load image: {e}")
91
+ self.status_label.setVisible(True)
92
  self.clear_plots()
93
  return
94
 
95
+ # Good: hide status
96
+ self.status_label.setVisible(False)
97
+
98
+ # -------------------- Histogram --------------------
99
+ try:
100
+ self.hist_ax.cla()
101
+ self.hist_ax.set_title('Grayscale histogram')
102
+ self.hist_ax.set_xlabel('Intensity')
103
+ self.hist_ax.set_ylabel('Count')
104
+ # ensure int range 0..255
105
+ flat = gray.ravel()
106
+ # handle float data by scaling if necessary
107
+ if flat.dtype.kind == 'f' and flat.max() <= 1.0:
108
+ flat = (flat * 255.0).astype(np.uint8)
109
+ self.hist_ax.hist(flat, bins=256, range=(0, 255))
110
+ self.hist_canvas.draw()
111
+ except Exception as e:
112
+ self.hist_ax.cla()
113
+ self.hist_canvas.draw()
114
+ self.status_label.setText(f"Histogram error: {e}")
115
+ self.status_label.setVisible(True)
116
+
117
+ # -------------------- FFT magnitude --------------------
118
+ try:
119
+ mag, mag_log = compute_fft_magnitude(gray)
120
+ if mag_log is None:
121
+ raise ValueError("compute_fft_magnitude returned None")
122
+
123
+ self.fft_ax.cla()
124
+ self.fft_ax.set_title('FFT magnitude (log)')
125
+ # use imshow with origin='lower' so low-frequencies sit near the centre visually
126
+ self.fft_ax.imshow(mag_log, origin='lower', aspect='auto')
127
+ self.fft_ax.set_xticks([])
128
+ self.fft_ax.set_yticks([])
129
+ # leave some room for colorbar in wider layouts (MainWindow adjusts overall sizes)
130
+ try:
131
+ self.fft_canvas.figure.subplots_adjust(right=0.92)
132
+ except Exception:
133
+ pass
134
+ self.fft_canvas.draw()
135
+ except Exception as e:
136
+ self.fft_ax.cla()
137
+ self.fft_canvas.draw()
138
+ self.status_label.setText(f"FFT error: {e}")
139
+ self.status_label.setVisible(True)
140
+
141
+ # -------------------- Radial profile --------------------
142
+ try:
143
+ centers, radial = radial_profile(mag)
144
+ if centers is None or radial is None:
145
+ raise ValueError("radial_profile returned invalid data")
146
+
147
+ self.radial_ax.cla()
148
+ self.radial_ax.set_title('Radial freq profile')
149
+ self.radial_ax.set_xlabel('Normalized radius')
150
+ self.radial_ax.set_ylabel('Mean magnitude')
151
+ self.radial_ax.plot(centers, radial)
152
+ self.radial_canvas.draw()
153
+ except Exception as e:
154
+ self.radial_ax.cla()
155
+ self.radial_canvas.draw()
156
+ self.status_label.setText(f"Radial profile error: {e}")
157
+ self.status_label.setVisible(True)
158
 
159
  def clear_plots(self):
160
+ """Clear all axes and redraw empty canvases."""
161
  for ax, canvas in ((self.hist_ax, self.hist_canvas), (self.fft_ax, self.fft_canvas), (self.radial_ax, self.radial_canvas)):
162
+ try:
163
+ ax.cla()
164
+ # give an empty centered message on histogram axis only
165
+ if ax is self.hist_ax:
166
+ ax.text(0.5, 0.5, 'No image', horizontalalignment='center', verticalalignment='center', transform=ax.transAxes)
167
+ canvas.draw()
168
+ except Exception:
169
+ pass
image_postprocess/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- from .image_postprocess_with_camera_pipeline import process_image
2
 
3
  __all__ = ['process_image']
 
1
+ from .processor import process_image
2
 
3
  __all__ = ['process_image']
image_postprocess/{image_postprocess_with_camera_pipeline.py → processor.py} RENAMED
@@ -1,10 +1,9 @@
1
  #!/usr/bin/env python3
2
  """
3
- image_postprocess_with_camera_pipeline.py
4
 
5
  Main pipeline for image postprocessing with an optional realistic camera-pipeline simulator.
6
- This file retains the original interface for CLI and imports, ensuring compatibility with existing code.
7
- Imports helper functions and camera pipeline simulation from separate modules.
8
  """
9
 
10
  import argparse
@@ -20,20 +19,21 @@ from .utils import (
20
  randomized_perturbation,
21
  fourier_match_spectrum,
22
  auto_white_balance_ref,
 
 
23
  )
24
  from .camera_pipeline import simulate_camera_pipeline
25
 
 
26
  def add_fake_exif():
27
  """
28
  Generates a plausible set of fake EXIF data.
29
  Returns:
30
  bytes: The EXIF data as a byte string, ready for insertion.
31
  """
32
- # Get current time for timestamp
33
  now = datetime.now()
34
  datestamp = now.strftime("%Y:%m:%d %H:%M:%S")
35
 
36
- # Define some plausible fake EXIF tags
37
  zeroth_ifd = {
38
  piexif.ImageIFD.Make: b"PurinCamera",
39
  piexif.ImageIFD.Model: b"Model420X",
@@ -46,17 +46,17 @@ def add_fake_exif():
46
  piexif.ExifIFD.ExposureTime: (1, 125), # 1/125s
47
  piexif.ExifIFD.FNumber: (28, 10), # F/2.8
48
  piexif.ExifIFD.ISOSpeedRatings: 200,
49
- piexif.ExifIFD.FocalLength: (50, 1), # 50mm
50
  }
51
- gps_ifd = {} # Empty GPS info
52
 
53
  exif_dict = {"0th": zeroth_ifd, "Exif": exif_ifd, "GPS": gps_ifd, "1st": {}, "thumbnail": None}
54
  exif_bytes = piexif.dump(exif_dict)
55
  return exif_bytes
56
 
 
57
  def process_image(path_in, path_out, args):
58
  img = Image.open(path_in).convert('RGB')
59
- # input -> numpy array
60
  arr = np.array(img)
61
 
62
  # --- Auto white-balance using reference (if provided) ---
@@ -105,14 +105,27 @@ def process_image(path_in, path_out, args):
105
  motion_blur_kernel=args.motion_blur_kernel,
106
  seed=args.seed)
107
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  out_img = Image.fromarray(arr)
109
-
110
  # Generate fake EXIF data and save it with the image
111
  fake_exif_bytes = add_fake_exif()
112
  out_img.save(path_out, exif=fake_exif_bytes)
113
 
 
114
  def build_argparser():
115
- p = argparse.ArgumentParser(description="Image postprocessing pipeline with camera simulation")
116
  p.add_argument('input', help='Input image path')
117
  p.add_argument('output', help='Output image path')
118
  p.add_argument('--ref', help='Optional reference image for auto white-balance (applied before CLAHE)', default=None)
@@ -147,8 +160,13 @@ def build_argparser():
147
  p.add_argument('--banding-strength', type=float, default=0.0, help='Horizontal banding amplitude (0..1)')
148
  p.add_argument('--motion-blur-kernel', type=int, default=1, help='Motion blur kernel size (1 = none)')
149
 
 
 
 
 
150
  return p
151
 
 
152
  if __name__ == "__main__":
153
  args = build_argparser().parse_args()
154
  if not os.path.exists(args.input):
 
1
  #!/usr/bin/env python3
2
  """
3
+ processor.py
4
 
5
  Main pipeline for image postprocessing with an optional realistic camera-pipeline simulator.
6
+ Added support for applying 1D PNG/.npy LUTs and .cube 3D LUTs via --lut.
 
7
  """
8
 
9
  import argparse
 
19
  randomized_perturbation,
20
  fourier_match_spectrum,
21
  auto_white_balance_ref,
22
+ load_lut,
23
+ apply_lut,
24
  )
25
  from .camera_pipeline import simulate_camera_pipeline
26
 
27
+
28
  def add_fake_exif():
29
  """
30
  Generates a plausible set of fake EXIF data.
31
  Returns:
32
  bytes: The EXIF data as a byte string, ready for insertion.
33
  """
 
34
  now = datetime.now()
35
  datestamp = now.strftime("%Y:%m:%d %H:%M:%S")
36
 
 
37
  zeroth_ifd = {
38
  piexif.ImageIFD.Make: b"PurinCamera",
39
  piexif.ImageIFD.Model: b"Model420X",
 
46
  piexif.ExifIFD.ExposureTime: (1, 125), # 1/125s
47
  piexif.ExifIFD.FNumber: (28, 10), # F/2.8
48
  piexif.ExifIFD.ISOSpeedRatings: 200,
49
+ piexif.ExifIFD.FocalLength: (50, 1), # 50mm
50
  }
51
+ gps_ifd = {}
52
 
53
  exif_dict = {"0th": zeroth_ifd, "Exif": exif_ifd, "GPS": gps_ifd, "1st": {}, "thumbnail": None}
54
  exif_bytes = piexif.dump(exif_dict)
55
  return exif_bytes
56
 
57
+
58
  def process_image(path_in, path_out, args):
59
  img = Image.open(path_in).convert('RGB')
 
60
  arr = np.array(img)
61
 
62
  # --- Auto white-balance using reference (if provided) ---
 
105
  motion_blur_kernel=args.motion_blur_kernel,
106
  seed=args.seed)
107
 
108
+ # --- LUT application (optional) ---
109
+ if args.lut:
110
+ try:
111
+ lut = load_lut(args.lut)
112
+ # Ensure array is uint8 for LUT application
113
+ arr_uint8 = np.clip(arr, 0, 255).astype(np.uint8)
114
+ arr_lut = apply_lut(arr_uint8, lut, strength=args.lut_strength)
115
+ # Ensure output is uint8
116
+ arr = np.clip(arr_lut, 0, 255).astype(np.uint8)
117
+ except Exception as e:
118
+ print(f"Warning: failed to load/apply LUT '{args.lut}': {e}. Skipping LUT.")
119
+
120
  out_img = Image.fromarray(arr)
121
+
122
  # Generate fake EXIF data and save it with the image
123
  fake_exif_bytes = add_fake_exif()
124
  out_img.save(path_out, exif=fake_exif_bytes)
125
 
126
+
127
  def build_argparser():
128
+ p = argparse.ArgumentParser(description="Image postprocessing pipeline with camera simulation and LUT support")
129
  p.add_argument('input', help='Input image path')
130
  p.add_argument('output', help='Output image path')
131
  p.add_argument('--ref', help='Optional reference image for auto white-balance (applied before CLAHE)', default=None)
 
160
  p.add_argument('--banding-strength', type=float, default=0.0, help='Horizontal banding amplitude (0..1)')
161
  p.add_argument('--motion-blur-kernel', type=int, default=1, help='Motion blur kernel size (1 = none)')
162
 
163
+ # LUT options
164
+ p.add_argument('--lut', type=str, default=None, help='Path to a 1D PNG (256x1) or .npy LUT, or a .cube 3D LUT')
165
+ p.add_argument('--lut-strength', type=float, default=1.0, help='Strength to blend LUT (0.0 = no effect, 1.0 = full LUT)')
166
+
167
  return p
168
 
169
+
170
  if __name__ == "__main__":
171
  args = build_argparser().parse_args()
172
  if not os.path.exists(args.input):
image_postprocess/utils.py CHANGED
@@ -4,7 +4,8 @@ utils.py
4
  Helper functions for image postprocessing, including EXIF removal, noise addition,
5
  color correction, and Fourier spectrum matching.
6
  """
7
-
 
8
  from PIL import Image, ImageOps
9
  import numpy as np
10
  try:
@@ -229,4 +230,227 @@ def auto_white_balance_ref(img_arr: np.ndarray, ref_img_arr: np.ndarray = None)
229
  corrected = img * scale
230
  corrected = np.clip(corrected, 0, 255).astype(np.uint8)
231
 
232
- return corrected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  Helper functions for image postprocessing, including EXIF removal, noise addition,
5
  color correction, and Fourier spectrum matching.
6
  """
7
+ import os
8
+ import re
9
  from PIL import Image, ImageOps
10
  import numpy as np
11
  try:
 
230
  corrected = img * scale
231
  corrected = np.clip(corrected, 0, 255).astype(np.uint8)
232
 
233
+ return corrected
234
+
235
+ def apply_1d_lut(img_arr: np.ndarray, lut: np.ndarray, strength: float = 1.0) -> np.ndarray:
236
+ """
237
+ Apply a 1D LUT to an image.
238
+ - img_arr: HxWx3 uint8
239
+ - lut: either shape (256,) (applied equally to all channels), (256,3) (per-channel),
240
+ or (N,) / (N,3) (interpolated across [0..255])
241
+ - strength: 0..1 blending between original and LUT result
242
+ Returns uint8 array.
243
+ """
244
+ if img_arr.ndim != 3 or img_arr.shape[2] != 3:
245
+ raise ValueError("apply_1d_lut expects an HxWx3 image array")
246
+
247
+ # Normalize indices 0..255
248
+ arr = img_arr.astype(np.float32)
249
+ # Prepare LUT as float in 0..255 range if necessary
250
+ lut_arr = np.array(lut, dtype=np.float32)
251
+
252
+ # If single channel LUT (N,) expand to three channels
253
+ if lut_arr.ndim == 1:
254
+ lut_arr = np.stack([lut_arr, lut_arr, lut_arr], axis=1) # (N,3)
255
+
256
+ if lut_arr.shape[1] != 3:
257
+ raise ValueError("1D LUT must have shape (N,) or (N,3)")
258
+
259
+ # Build index positions in source LUT space (0..255)
260
+ N = lut_arr.shape[0]
261
+ src_positions = np.linspace(0, 255, N)
262
+
263
+ # Flatten and interpolate per channel
264
+ out = np.empty_like(arr)
265
+ for c in range(3):
266
+ channel = arr[..., c].ravel()
267
+ mapped = np.interp(channel, src_positions, lut_arr[:, c])
268
+ out[..., c] = mapped.reshape(arr.shape[0], arr.shape[1])
269
+
270
+ out = np.clip(out, 0, 255).astype(np.uint8)
271
+ if strength >= 1.0:
272
+ return out
273
+ else:
274
+ blended = ((1.0 - strength) * img_arr.astype(np.float32) + strength * out.astype(np.float32))
275
+ return np.clip(blended, 0, 255).astype(np.uint8)
276
+
277
+ def _trilinear_sample_lut(img_float: np.ndarray, lut: np.ndarray) -> np.ndarray:
278
+ """
279
+ Vectorized trilinear sampling of 3D LUT.
280
+ - img_float: HxWx3 floats in [0,1]
281
+ - lut: SxSxS x 3 floats in [0,1]
282
+ Returns HxWx3 floats in [0,1]
283
+ """
284
+ S = lut.shape[0]
285
+ if lut.shape[0] != lut.shape[1] or lut.shape[1] != lut.shape[2]:
286
+ raise ValueError("3D LUT must be cubic (SxSxSx3)")
287
+
288
+ # map [0,1] -> [0, S-1]
289
+ idx = img_float * (S - 1)
290
+ r_idx = idx[..., 0]
291
+ g_idx = idx[..., 1]
292
+ b_idx = idx[..., 2]
293
+
294
+ r0 = np.floor(r_idx).astype(np.int32)
295
+ g0 = np.floor(g_idx).astype(np.int32)
296
+ b0 = np.floor(b_idx).astype(np.int32)
297
+
298
+ r1 = np.clip(r0 + 1, 0, S - 1)
299
+ g1 = np.clip(g0 + 1, 0, S - 1)
300
+ b1 = np.clip(b0 + 1, 0, S - 1)
301
+
302
+ dr = (r_idx - r0)[..., None]
303
+ dg = (g_idx - g0)[..., None]
304
+ db = (b_idx - b0)[..., None]
305
+
306
+ # gather 8 corners: c000 ... c111
307
+ c000 = lut[r0, g0, b0]
308
+ c001 = lut[r0, g0, b1]
309
+ c010 = lut[r0, g1, b0]
310
+ c011 = lut[r0, g1, b1]
311
+ c100 = lut[r1, g0, b0]
312
+ c101 = lut[r1, g0, b1]
313
+ c110 = lut[r1, g1, b0]
314
+ c111 = lut[r1, g1, b1]
315
+
316
+ # interpolate along b
317
+ c00 = c000 * (1 - db) + c001 * db
318
+ c01 = c010 * (1 - db) + c011 * db
319
+ c10 = c100 * (1 - db) + c101 * db
320
+ c11 = c110 * (1 - db) + c111 * db
321
+
322
+ # interpolate along g
323
+ c0 = c00 * (1 - dg) + c01 * dg
324
+ c1 = c10 * (1 - dg) + c11 * dg
325
+
326
+ # interpolate along r
327
+ c = c0 * (1 - dr) + c1 * dr
328
+
329
+ return c # float in same range as lut (expected [0,1])
330
+
331
+ def apply_3d_lut(img_arr: np.ndarray, lut3d: np.ndarray, strength: float = 1.0) -> np.ndarray:
332
+ """
333
+ Apply a 3D LUT to the image.
334
+ - img_arr: HxWx3 uint8
335
+ - lut3d: SxSxSx3 float (expected range 0..1)
336
+ - strength: blending 0..1
337
+ Returns uint8 image.
338
+ """
339
+ if img_arr.ndim != 3 or img_arr.shape[2] != 3:
340
+ raise ValueError("apply_3d_lut expects an HxWx3 image array")
341
+
342
+ img_float = img_arr.astype(np.float32) / 255.0
343
+ sampled = _trilinear_sample_lut(img_float, lut3d) # HxWx3 floats in [0,1]
344
+ out = np.clip(sampled * 255.0, 0, 255).astype(np.uint8)
345
+ if strength >= 1.0:
346
+ return out
347
+ else:
348
+ blended = ((1.0 - strength) * img_arr.astype(np.float32) + strength * out.astype(np.float32))
349
+ return np.clip(blended, 0, 255).astype(np.uint8)
350
+
351
+ def apply_lut(img_arr: np.ndarray, lut: np.ndarray, strength: float = 1.0) -> np.ndarray:
352
+ """
353
+ Auto-detect LUT type and apply.
354
+ - If lut.ndim in (1,2) treat as 1D LUT (per-channel if shape (N,3)).
355
+ - If lut.ndim == 4 treat as 3D LUT (SxSxSx3) in [0,1].
356
+ """
357
+ lut = np.array(lut)
358
+ if lut.ndim == 4 and lut.shape[3] == 3:
359
+ # 3D LUT (assumed normalized [0..1])
360
+ # If lut is in 0..255, normalize
361
+ if lut.dtype != np.float32 and lut.max() > 1.0:
362
+ lut = lut.astype(np.float32) / 255.0
363
+ return apply_3d_lut(img_arr, lut, strength=strength)
364
+ elif lut.ndim in (1, 2):
365
+ return apply_1d_lut(img_arr, lut, strength=strength)
366
+ else:
367
+ raise ValueError("Unsupported LUT shape: {}".format(lut.shape))
368
+
369
+ def load_cube_lut(path: str) -> np.ndarray:
370
+ """
371
+ Parse a .cube file and return a 3D LUT array of shape (S,S,S,3) with float values in [0,1].
372
+ Note: .cube file order sometimes varies; this function assumes standard ordering
373
+ where data lines are triples of floats and LUT_3D_SIZE specifies S.
374
+ """
375
+ with open(path, 'r', encoding='utf-8', errors='ignore') as f:
376
+ lines = [ln.strip() for ln in f if ln.strip() and not ln.strip().startswith('#')]
377
+
378
+ size = None
379
+ data = []
380
+ domain_min = np.array([0.0, 0.0, 0.0], dtype=np.float32)
381
+ domain_max = np.array([1.0, 1.0, 1.0], dtype=np.float32)
382
+
383
+ for ln in lines:
384
+ if ln.upper().startswith('LUT_3D_SIZE'):
385
+ parts = ln.split()
386
+ if len(parts) >= 2:
387
+ size = int(parts[1])
388
+ elif ln.upper().startswith('DOMAIN_MIN'):
389
+ parts = ln.split()
390
+ domain_min = np.array([float(p) for p in parts[1:4]], dtype=np.float32)
391
+ elif ln.upper().startswith('DOMAIN_MAX'):
392
+ parts = ln.split()
393
+ domain_max = np.array([float(p) for p in parts[1:4]], dtype=np.float32)
394
+ elif re.match(r'^-?\d+(\.\d+)?\s+-?\d+(\.\d+)?\s+-?\d+(\.\d+)?$', ln):
395
+ parts = [float(x) for x in ln.split()]
396
+ data.append(parts)
397
+
398
+ if size is None:
399
+ raise ValueError("LUT_3D_SIZE not found in .cube file: {}".format(path))
400
+
401
+ data = np.array(data, dtype=np.float32)
402
+ if data.shape[0] != size**3:
403
+ raise ValueError("Cube LUT data length does not match size^3 (got {}, expected {})".format(data.shape[0], size**3))
404
+
405
+ # Data ordering in many .cube files is: for r in 0..S-1: for g in 0..S-1: for b in 0..S-1: write RGB
406
+ # We'll reshape into (S,S,S,3) with indices [r,g,b]
407
+ lut = data.reshape((size, size, size, 3))
408
+ # Map domain_min..domain_max to 0..1 if domain specified (rare)
409
+ if not np.allclose(domain_min, [0.0, 0.0, 0.0]) or not np.allclose(domain_max, [1.0, 1.0, 1.0]):
410
+ # scale lut values from domain range into 0..1
411
+ lut = (lut - domain_min) / (domain_max - domain_min + 1e-12)
412
+ lut = np.clip(lut, 0.0, 1.0)
413
+ else:
414
+ # ensure LUT is in [0,1] if not already
415
+ if lut.max() > 1.0 + 1e-6:
416
+ lut = lut / 255.0
417
+ return lut.astype(np.float32)
418
+
419
+ def load_lut(path: str) -> np.ndarray:
420
+ """
421
+ Load a LUT from:
422
+ - .npy (numpy saved array)
423
+ - .cube (3D LUT)
424
+ - image (PNG/JPG) that is a 1D LUT strip (common 256x1 or 1x256)
425
+ Returns numpy array (1D, 2D, or 4D LUT).
426
+ """
427
+ ext = os.path.splitext(path)[1].lower()
428
+ if ext == '.npy':
429
+ return np.load(path)
430
+ elif ext == '.cube':
431
+ return load_cube_lut(path)
432
+ else:
433
+ # try interpreting as image-based 1D LUT
434
+ try:
435
+ im = Image.open(path).convert('RGB')
436
+ arr = np.array(im)
437
+ h, w = arr.shape[:2]
438
+ # 256x1 or 1x256 typical 1D LUT
439
+ if (w == 256 and h == 1) or (h == 256 and w == 1):
440
+ if h == 1:
441
+ lut = arr[0, :, :].astype(np.float32)
442
+ else:
443
+ lut = arr[:, 0, :].astype(np.float32)
444
+ return lut # shape (256,3)
445
+ # sometimes embedded as 512x16 or other tile layouts; attempt to flatten
446
+ # fallback: flatten and try to build (N,3)
447
+ flat = arr.reshape(-1, 3).astype(np.float32)
448
+ # if length is perfect power-of-two and <= 1024, assume 1D
449
+ L = flat.shape[0]
450
+ if L <= 4096:
451
+ return flat # (L,3)
452
+ raise ValueError("Image LUT not recognized size")
453
+ except Exception as e:
454
+ raise ValueError(f"Unsupported LUT file or parse error for {path}: {e}")
455
+
456
+ # --- end appended LUT helpers
run.py CHANGED
@@ -1,6 +1,8 @@
1
  #!/usr/bin/env python3
2
  """
3
  Main GUI application for image_postprocess pipeline with camera-simulator controls.
 
 
4
  """
5
 
6
  import sys
@@ -9,7 +11,7 @@ from pathlib import Path
9
  from PyQt5.QtWidgets import (
10
  QApplication, QMainWindow, QWidget, QLabel, QPushButton, QFileDialog,
11
  QHBoxLayout, QVBoxLayout, QFormLayout, QSlider, QSpinBox, QDoubleSpinBox,
12
- QProgressBar, QMessageBox, QLineEdit, QComboBox, QCheckBox, QToolButton
13
  )
14
  from PyQt5.QtCore import Qt
15
  from PyQt5.QtGui import QPixmap
@@ -100,9 +102,15 @@ class MainWindow(QMainWindow):
100
  self.progress.setValue(0)
101
  left_v.addWidget(self.progress)
102
 
103
- # Right: controls + analysis panels
104
- right_v = QVBoxLayout()
105
- main_h.addLayout(right_v, 3)
 
 
 
 
 
 
106
 
107
  # Auto Mode toggle (keeps top-level quick switch visible)
108
  self.auto_mode_chk = QCheckBox("Enable Auto Mode")
@@ -233,6 +241,37 @@ class MainWindow(QMainWindow):
233
  self.sim_camera_chk.stateChanged.connect(self._on_sim_camera_toggled)
234
  params_layout.addRow(self.sim_camera_chk)
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # Camera simulator collapsible group
237
  self.camera_box = CollapsibleBox("Camera simulator options")
238
  right_v.addWidget(self.camera_box)
@@ -374,6 +413,16 @@ class MainWindow(QMainWindow):
374
  if path:
375
  self.output_line.setText(path)
376
 
 
 
 
 
 
 
 
 
 
 
377
  def load_preview(self, widget: QLabel, path: str):
378
  if not path or not os.path.exists(path):
379
  widget.setText("No image")
@@ -469,6 +518,15 @@ class MainWindow(QMainWindow):
469
  # FFT spectral matching reference
470
  args.fft_ref = fft_ref_val
471
 
 
 
 
 
 
 
 
 
 
472
  self.worker = Worker(inpath, outpath, args)
473
  self.worker.finished.connect(self.on_finished)
474
  self.worker.error.connect(self.on_error)
@@ -544,4 +602,4 @@ def main():
544
  sys.exit(app.exec_())
545
 
546
  if __name__ == '__main__':
547
- main()
 
1
  #!/usr/bin/env python3
2
  """
3
  Main GUI application for image_postprocess pipeline with camera-simulator controls.
4
+
5
+ Updated: added LUT support UI (enable checkbox, file chooser, strength) and wiring to on_run.
6
  """
7
 
8
  import sys
 
11
  from PyQt5.QtWidgets import (
12
  QApplication, QMainWindow, QWidget, QLabel, QPushButton, QFileDialog,
13
  QHBoxLayout, QVBoxLayout, QFormLayout, QSlider, QSpinBox, QDoubleSpinBox,
14
+ QProgressBar, QMessageBox, QLineEdit, QComboBox, QCheckBox, QToolButton, QScrollArea
15
  )
16
  from PyQt5.QtCore import Qt
17
  from PyQt5.QtGui import QPixmap
 
102
  self.progress.setValue(0)
103
  left_v.addWidget(self.progress)
104
 
105
+ # Right: controls + analysis panels (with scroll area)
106
+ scroll_area = QScrollArea()
107
+ scroll_area.setWidgetResizable(True)
108
+ scroll_area.setStyleSheet("QScrollArea { border: none; }")
109
+ main_h.addWidget(scroll_area, 3)
110
+
111
+ scroll_widget = QWidget()
112
+ right_v = QVBoxLayout(scroll_widget)
113
+ scroll_area.setWidget(scroll_widget)
114
 
115
  # Auto Mode toggle (keeps top-level quick switch visible)
116
  self.auto_mode_chk = QCheckBox("Enable Auto Mode")
 
241
  self.sim_camera_chk.stateChanged.connect(self._on_sim_camera_toggled)
242
  params_layout.addRow(self.sim_camera_chk)
243
 
244
+ # --- LUT support UI ---
245
+ self.lut_chk = QCheckBox("Enable LUT")
246
+ self.lut_chk.setChecked(False)
247
+ self.lut_chk.setToolTip("Enable applying a 1D/.npy/.cube LUT to the output image")
248
+ self.lut_chk.stateChanged.connect(self._on_lut_toggled)
249
+ params_layout.addRow(self.lut_chk)
250
+
251
+ # LUT chooser (hidden until checkbox checked)
252
+ self.lut_line = QLineEdit()
253
+ self.lut_btn = QPushButton("Choose LUT")
254
+ self.lut_btn.clicked.connect(self.choose_lut)
255
+ lut_box = QWidget()
256
+ lut_box_layout = QHBoxLayout()
257
+ lut_box_layout.setContentsMargins(0, 0, 0, 0)
258
+ lut_box.setLayout(lut_box_layout)
259
+ lut_box_layout.addWidget(self.lut_line)
260
+ lut_box_layout.addWidget(self.lut_btn)
261
+ params_layout.addRow("LUT file (png/.npy/.cube)", lut_box)
262
+
263
+ self.lut_strength_spin = QDoubleSpinBox()
264
+ self.lut_strength_spin.setRange(0.0, 1.0)
265
+ self.lut_strength_spin.setSingleStep(0.01)
266
+ self.lut_strength_spin.setValue(1.0)
267
+ self.lut_strength_spin.setToolTip("Blend strength for LUT (0.0 = no effect, 1.0 = full LUT)")
268
+ params_layout.addRow("LUT strength", self.lut_strength_spin)
269
+
270
+ # Initially hide LUT controls
271
+ lut_box.setVisible(False)
272
+ self.lut_strength_spin.setVisible(False)
273
+ self._lut_controls = (lut_box, self.lut_strength_spin)
274
+
275
  # Camera simulator collapsible group
276
  self.camera_box = CollapsibleBox("Camera simulator options")
277
  right_v.addWidget(self.camera_box)
 
413
  if path:
414
  self.output_line.setText(path)
415
 
416
+ def choose_lut(self):
417
+ path, _ = QFileDialog.getOpenFileName(self, "Choose LUT file", str(Path.home()), "LUTs (*.png *.npy *.cube);;All files (*)")
418
+ if path:
419
+ self.lut_line.setText(path)
420
+
421
+ def _on_lut_toggled(self, state):
422
+ visible = (state == Qt.Checked)
423
+ for w in self._lut_controls:
424
+ w.setVisible(visible)
425
+
426
  def load_preview(self, widget: QLabel, path: str):
427
  if not path or not os.path.exists(path):
428
  widget.setText("No image")
 
518
  # FFT spectral matching reference
519
  args.fft_ref = fft_ref_val
520
 
521
+ # LUT handling: only include if LUT checkbox is checked and a path is provided
522
+ if self.lut_chk.isChecked():
523
+ lut_path = self.lut_line.text().strip()
524
+ args.lut = lut_path if lut_path else None
525
+ args.lut_strength = float(self.lut_strength_spin.value())
526
+ else:
527
+ args.lut = None
528
+ args.lut_strength = 1.0
529
+
530
  self.worker = Worker(inpath, outpath, args)
531
  self.worker.finished.connect(self.on_finished)
532
  self.worker.error.connect(self.on_error)
 
602
  sys.exit(app.exec_())
603
 
604
  if __name__ == '__main__':
605
+ main()