GitHub Action commited on
Commit
4e9afb0
·
1 Parent(s): 188604c

Auto-deploy from GitHub Actions

Browse files
Files changed (3) hide show
  1. app.py +8 -5
  2. coverage.xml +75 -85
  3. magiceye_solve/solver.py +103 -90
app.py CHANGED
@@ -11,6 +11,9 @@ from typing import Tuple
11
  import warnings
12
  warnings.filterwarnings("ignore", message=".*Trying to detect.*share=True.*")
13
 
 
 
 
14
  def create_autocorrelation_plot(autocorrelation_curve: np.ndarray, current_slider_offset: int, solver_calculated_offset: int, peak_diffs: np.ndarray, peak_indices: np.ndarray = None) -> plt.Figure:
15
  """
16
  Generates a matplotlib plot of the autocorrelation curve with vertical lines
@@ -67,9 +70,6 @@ def create_autocorrelation_plot(autocorrelation_curve: np.ndarray, current_slide
67
  colors[selected_peak_idx] = 'r' # Highlight the selected peak in red
68
  sizes[selected_peak_idx] = 60 # Make the selected peak larger
69
 
70
- # Plot all peaks
71
- #ax.scatter(shifted_peak_indices, peak_y_values, c=colors, s=sizes, marker='o', label='Detected Peaks')
72
-
73
  # Specifically label the peak used for the selected offset
74
  if selected_peak_idx is not None:
75
  peak_idx = peak_indices[selected_peak_idx]
@@ -102,7 +102,9 @@ def create_autocorrelation_plot(autocorrelation_curve: np.ndarray, current_slide
102
 
103
  # Set appropriate x-axis limits to focus on relevant part of the curve
104
  # Use this same limit for the slider's range
105
- x_limit = min(700, current_slider_offset * 2, solver_calculated_offset * 2)
 
 
106
  ax.set_xlim([0, x_limit])
107
 
108
  # Add title and labels
@@ -163,7 +165,8 @@ def process_image(uploaded_image: np.ndarray):
163
  solver = InteractiveSolver(uploaded_image)
164
 
165
  min_offset = 1
166
- max_offset = min(700, solver.default_offset * 2)
 
167
  default_offset = solver.default_offset
168
  default_offset = max(min_offset, min(default_offset, max_offset))
169
 
 
11
  import warnings
12
  warnings.filterwarnings("ignore", message=".*Trying to detect.*share=True.*")
13
 
14
+ # constants
15
+ MAX_OFFSET_DISPLAY_LIMIT = 700 # max value for display and slider range for offset
16
+
17
  def create_autocorrelation_plot(autocorrelation_curve: np.ndarray, current_slider_offset: int, solver_calculated_offset: int, peak_diffs: np.ndarray, peak_indices: np.ndarray = None) -> plt.Figure:
18
  """
19
  Generates a matplotlib plot of the autocorrelation curve with vertical lines
 
70
  colors[selected_peak_idx] = 'r' # Highlight the selected peak in red
71
  sizes[selected_peak_idx] = 60 # Make the selected peak larger
72
 
 
 
 
73
  # Specifically label the peak used for the selected offset
74
  if selected_peak_idx is not None:
75
  peak_idx = peak_indices[selected_peak_idx]
 
102
 
103
  # Set appropriate x-axis limits to focus on relevant part of the curve
104
  # Use this same limit for the slider's range
105
+ # ensure x_limit is at least a small positive number if offsets are 0 or very small
106
+ relevant_offset_max = max(10, current_slider_offset * 2, solver_calculated_offset * 2) # ensure a minimum sensible view
107
+ x_limit = min(MAX_OFFSET_DISPLAY_LIMIT, relevant_offset_max)
108
  ax.set_xlim([0, x_limit])
109
 
110
  # Add title and labels
 
165
  solver = InteractiveSolver(uploaded_image)
166
 
167
  min_offset = 1
168
+ # ensure max_offset is at least min_offset and considers solver's suggestion
169
+ max_offset = min(MAX_OFFSET_DISPLAY_LIMIT, max(min_offset +1 , solver.default_offset * 2))
170
  default_offset = solver.default_offset
171
  default_offset = max(min_offset, min(default_offset, max_offset))
172
 
coverage.xml CHANGED
@@ -1,12 +1,12 @@
1
  <?xml version="1.0" ?>
2
- <coverage version="7.8.2" timestamp="1748027079655" lines-valid="404" lines-covered="370" line-rate="0.9158" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
3
  <!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.8.2 -->
4
  <!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
5
  <sources>
6
  <source>/home/runner/work/magiceye-solver/magiceye-solver</source>
7
  </sources>
8
  <packages>
9
- <package name="magiceye_solve" line-rate="0.8086" branch-rate="0" complexity="0">
10
  <classes>
11
  <class name="__init__.py" filename="magiceye_solve/__init__.py" complexity="0" line-rate="1" branch-rate="0">
12
  <methods/>
@@ -14,7 +14,7 @@
14
  <line number="1" hits="1"/>
15
  </lines>
16
  </class>
17
- <class name="solver.py" filename="magiceye_solve/solver.py" complexity="0" line-rate="0.8075" branch-rate="0">
18
  <methods/>
19
  <lines>
20
  <line number="1" hits="1"/>
@@ -33,83 +33,81 @@
33
  <line number="15" hits="0"/>
34
  <line number="16" hits="0"/>
35
  <line number="18" hits="1"/>
36
- <line number="20" hits="1"/>
37
- <line number="26" hits="1"/>
38
- <line number="27" hits="1"/>
39
- <line number="28" hits="1"/>
40
- <line number="29" hits="1"/>
41
  <line number="31" hits="1"/>
42
- <line number="32" hits="0"/>
43
  <line number="33" hits="1"/>
44
- <line number="35" hits="1"/>
45
  <line number="36" hits="1"/>
46
- <line number="38" hits="1"/>
47
  <line number="39" hits="1"/>
48
- <line number="40" hits="1"/>
49
  <line number="42" hits="1"/>
50
- <line number="43" hits="1"/>
51
  <line number="45" hits="1"/>
52
- <line number="46" hits="0"/>
53
- <line number="47" hits="0"/>
 
 
54
  <line number="50" hits="1"/>
55
  <line number="51" hits="1"/>
56
- <line number="53" hits="1"/>
57
- <line number="54" hits="1"/>
 
58
  <line number="56" hits="1"/>
59
- <line number="57" hits="1"/>
60
  <line number="58" hits="1"/>
61
- <line number="59" hits="0"/>
62
- <line number="60" hits="0"/>
63
- <line number="61" hits="0"/>
64
- <line number="63" hits="0"/>
65
  <line number="64" hits="0"/>
66
- <line number="66" hits="1"/>
67
- <line number="67" hits="0"/>
68
  <line number="68" hits="0"/>
69
  <line number="69" hits="0"/>
70
- <line number="70" hits="0"/>
71
- <line number="71" hits="0"/>
72
- <line number="72" hits="0"/>
73
- <line number="75" hits="1"/>
74
- <line number="76" hits="1"/>
75
- <line number="78" hits="1"/>
76
  <line number="80" hits="1"/>
77
  <line number="82" hits="1"/>
78
- <line number="86" hits="1"/>
79
- <line number="87" hits="1"/>
80
  <line number="88" hits="1"/>
81
  <line number="89" hits="1"/>
82
  <line number="90" hits="1"/>
 
83
  <line number="92" hits="1"/>
84
- <line number="93" hits="1"/>
85
  <line number="94" hits="1"/>
86
  <line number="95" hits="1"/>
 
87
  <line number="97" hits="1"/>
88
- <line number="101" hits="1"/>
89
- <line number="102" hits="1"/>
90
  <line number="103" hits="1"/>
91
  <line number="104" hits="1"/>
92
  <line number="105" hits="1"/>
93
  <line number="106" hits="1"/>
94
  <line number="107" hits="1"/>
95
  <line number="108" hits="1"/>
96
- <line number="109" hits="0"/>
97
- <line number="110" hits="0"/>
98
- <line number="112" hits="1"/>
99
- <line number="116" hits="1"/>
100
- <line number="124" hits="1"/>
101
- <line number="125" hits="1"/>
102
  <line number="126" hits="1"/>
103
- <line number="127" hits="0"/>
104
- <line number="129" hits="1"/>
105
- <line number="130" hits="0"/>
106
- <line number="132" hits="1"/>
107
- <line number="133" hits="1"/>
108
  <line number="134" hits="1"/>
109
- <line number="137" hits="1"/>
110
- <line number="138" hits="1"/>
 
111
  <line number="140" hits="1"/>
112
- <line number="141" hits="1"/>
113
  <line number="142" hits="1"/>
114
  <line number="143" hits="1"/>
115
  <line number="144" hits="1"/>
@@ -117,67 +115,59 @@
117
  <line number="146" hits="1"/>
118
  <line number="147" hits="1"/>
119
  <line number="148" hits="1"/>
120
- <line number="150" hits="0"/>
121
- <line number="152" hits="1"/>
122
- <line number="153" hits="1"/>
123
  <line number="154" hits="1"/>
 
124
  <line number="156" hits="1"/>
125
- <line number="157" hits="1"/>
126
  <line number="158" hits="1"/>
127
  <line number="159" hits="1"/>
 
128
  <line number="161" hits="1"/>
 
 
 
 
 
 
 
 
129
  <line number="174" hits="1"/>
130
  <line number="175" hits="1"/>
131
  <line number="176" hits="1"/>
132
- <line number="178" hits="1"/>
133
- <line number="180" hits="1"/>
134
  <line number="181" hits="1"/>
135
- <line number="182" hits="1"/>
136
- <line number="184" hits="1"/>
137
  <line number="185" hits="1"/>
138
  <line number="186" hits="1"/>
139
  <line number="187" hits="1"/>
140
- <line number="188" hits="1"/>
141
  <line number="189" hits="1"/>
142
- <line number="190" hits="1"/>
143
- <line number="191" hits="1"/>
144
- <line number="192" hits="1"/>
145
- <line number="193" hits="1"/>
146
- <line number="194" hits="1"/>
147
- <line number="195" hits="1"/>
148
- <line number="196" hits="1"/>
149
- <line number="197" hits="1"/>
150
- <line number="198" hits="1"/>
151
- <line number="200" hits="1"/>
152
- <line number="201" hits="1"/>
153
  <line number="202" hits="1"/>
154
- <line number="203" hits="1"/>
155
- <line number="204" hits="1"/>
156
- <line number="205" hits="0"/>
157
- <line number="206" hits="1"/>
158
- <line number="207" hits="0"/>
159
  <line number="208" hits="1"/>
160
- <line number="209" hits="1"/>
161
  <line number="210" hits="1"/>
162
  <line number="211" hits="1"/>
163
- <line number="212" hits="1"/>
164
  <line number="213" hits="1"/>
165
  <line number="214" hits="1"/>
166
  <line number="215" hits="1"/>
167
- <line number="216" hits="1"/>
168
  <line number="217" hits="1"/>
169
- <line number="218" hits="0"/>
170
- <line number="219" hits="0"/>
171
- <line number="220" hits="0"/>
172
- <line number="221" hits="0"/>
173
- <line number="222" hits="1"/>
174
  <line number="223" hits="1"/>
175
  <line number="224" hits="1"/>
176
- <line number="225" hits="1"/>
177
- <line number="226" hits="0"/>
178
- <line number="227" hits="0"/>
179
  <line number="228" hits="1"/>
 
180
  <line number="231" hits="1"/>
 
 
 
 
 
 
181
  </lines>
182
  </class>
183
  </classes>
 
1
  <?xml version="1.0" ?>
2
+ <coverage version="7.8.2" timestamp="1748050735477" lines-valid="394" lines-covered="367" line-rate="0.9315" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
3
  <!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.8.2 -->
4
  <!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
5
  <sources>
6
  <source>/home/runner/work/magiceye-solver/magiceye-solver</source>
7
  </sources>
8
  <packages>
9
+ <package name="magiceye_solve" line-rate="0.8421" branch-rate="0" complexity="0">
10
  <classes>
11
  <class name="__init__.py" filename="magiceye_solve/__init__.py" complexity="0" line-rate="1" branch-rate="0">
12
  <methods/>
 
14
  <line number="1" hits="1"/>
15
  </lines>
16
  </class>
17
+ <class name="solver.py" filename="magiceye_solve/solver.py" complexity="0" line-rate="0.8411" branch-rate="0">
18
  <methods/>
19
  <lines>
20
  <line number="1" hits="1"/>
 
33
  <line number="15" hits="0"/>
34
  <line number="16" hits="0"/>
35
  <line number="18" hits="1"/>
36
+ <line number="21" hits="1"/>
37
+ <line number="22" hits="1"/>
38
+ <line number="23" hits="1"/>
39
+ <line number="25" hits="1"/>
40
+ <line number="30" hits="1"/>
41
  <line number="31" hits="1"/>
 
42
  <line number="33" hits="1"/>
43
+ <line number="34" hits="1"/>
44
  <line number="36" hits="1"/>
45
+ <line number="37" hits="0"/>
46
  <line number="39" hits="1"/>
47
+ <line number="41" hits="1"/>
48
  <line number="42" hits="1"/>
49
+ <line number="44" hits="1"/>
50
  <line number="45" hits="1"/>
51
+ <line number="46" hits="1"/>
52
+ <line number="47" hits="1"/>
53
+ <line number="48" hits="1"/>
54
+ <line number="49" hits="1"/>
55
  <line number="50" hits="1"/>
56
  <line number="51" hits="1"/>
57
+ <line number="52" hits="0"/>
58
+ <line number="53" hits="0"/>
59
+ <line number="55" hits="1"/>
60
  <line number="56" hits="1"/>
 
61
  <line number="58" hits="1"/>
62
+ <line number="59" hits="1"/>
63
+ <line number="61" hits="1"/>
64
+ <line number="63" hits="1"/>
 
65
  <line number="64" hits="0"/>
66
+ <line number="65" hits="0"/>
67
+ <line number="66" hits="0"/>
68
  <line number="68" hits="0"/>
69
  <line number="69" hits="0"/>
70
+ <line number="72" hits="1"/>
71
+ <line number="73" hits="0"/>
72
+ <line number="74" hits="0"/>
73
+ <line number="75" hits="0"/>
74
+ <line number="79" hits="1"/>
 
75
  <line number="80" hits="1"/>
76
  <line number="82" hits="1"/>
77
+ <line number="84" hits="1"/>
 
78
  <line number="88" hits="1"/>
79
  <line number="89" hits="1"/>
80
  <line number="90" hits="1"/>
81
+ <line number="91" hits="1"/>
82
  <line number="92" hits="1"/>
 
83
  <line number="94" hits="1"/>
84
  <line number="95" hits="1"/>
85
+ <line number="96" hits="1"/>
86
  <line number="97" hits="1"/>
87
+ <line number="99" hits="1"/>
 
88
  <line number="103" hits="1"/>
89
  <line number="104" hits="1"/>
90
  <line number="105" hits="1"/>
91
  <line number="106" hits="1"/>
92
  <line number="107" hits="1"/>
93
  <line number="108" hits="1"/>
94
+ <line number="109" hits="1"/>
95
+ <line number="110" hits="1"/>
96
+ <line number="111" hits="0"/>
97
+ <line number="112" hits="0"/>
98
+ <line number="114" hits="1"/>
99
+ <line number="118" hits="1"/>
100
  <line number="126" hits="1"/>
101
+ <line number="127" hits="1"/>
102
+ <line number="128" hits="1"/>
103
+ <line number="129" hits="0"/>
104
+ <line number="131" hits="1"/>
105
+ <line number="132" hits="0"/>
106
  <line number="134" hits="1"/>
107
+ <line number="135" hits="1"/>
108
+ <line number="136" hits="1"/>
109
+ <line number="139" hits="1"/>
110
  <line number="140" hits="1"/>
 
111
  <line number="142" hits="1"/>
112
  <line number="143" hits="1"/>
113
  <line number="144" hits="1"/>
 
115
  <line number="146" hits="1"/>
116
  <line number="147" hits="1"/>
117
  <line number="148" hits="1"/>
118
+ <line number="149" hits="1"/>
119
+ <line number="150" hits="1"/>
120
+ <line number="152" hits="0"/>
121
  <line number="154" hits="1"/>
122
+ <line number="155" hits="1"/>
123
  <line number="156" hits="1"/>
 
124
  <line number="158" hits="1"/>
125
  <line number="159" hits="1"/>
126
+ <line number="160" hits="1"/>
127
  <line number="161" hits="1"/>
128
+ <line number="163" hits="1"/>
129
+ <line number="165" hits="1"/>
130
+ <line number="166" hits="1"/>
131
+ <line number="168" hits="1"/>
132
+ <line number="169" hits="1"/>
133
+ <line number="170" hits="1"/>
134
+ <line number="172" hits="1"/>
135
+ <line number="173" hits="1"/>
136
  <line number="174" hits="1"/>
137
  <line number="175" hits="1"/>
138
  <line number="176" hits="1"/>
139
+ <line number="179" hits="1"/>
140
+ <line number="180" hits="0"/>
141
  <line number="181" hits="1"/>
142
+ <line number="183" hits="0"/>
143
+ <line number="184" hits="0"/>
144
  <line number="185" hits="1"/>
145
  <line number="186" hits="1"/>
146
  <line number="187" hits="1"/>
 
147
  <line number="189" hits="1"/>
 
 
 
 
 
 
 
 
 
 
 
148
  <line number="202" hits="1"/>
 
 
 
 
 
149
  <line number="208" hits="1"/>
 
150
  <line number="210" hits="1"/>
151
  <line number="211" hits="1"/>
 
152
  <line number="213" hits="1"/>
153
  <line number="214" hits="1"/>
154
  <line number="215" hits="1"/>
 
155
  <line number="217" hits="1"/>
156
+ <line number="220" hits="1"/>
157
+ <line number="221" hits="1"/>
 
 
 
158
  <line number="223" hits="1"/>
159
  <line number="224" hits="1"/>
160
+ <line number="226" hits="1"/>
161
+ <line number="227" hits="1"/>
 
162
  <line number="228" hits="1"/>
163
+ <line number="229" hits="0"/>
164
  <line number="231" hits="1"/>
165
+ <line number="234" hits="1"/>
166
+ <line number="235" hits="1"/>
167
+ <line number="236" hits="1"/>
168
+ <line number="237" hits="1"/>
169
+ <line number="240" hits="1"/>
170
+ <line number="244" hits="1"/>
171
  </lines>
172
  </class>
173
  </classes>
magiceye_solve/solver.py CHANGED
@@ -17,65 +17,67 @@ except ImportError:
17
 
18
  from scipy.signal import fftconvolve
19
 
 
 
 
 
 
20
  def offset(img: np.ndarray) -> Tuple[int, np.ndarray, np.ndarray, np.ndarray]:
21
  """
22
  Calculates the offset that defines the stereoscopic effect.
23
- Now selects the offset corresponding to the highest peak in the autocorrelation curve.
24
  """
25
- # Handle empty or 1D arrays gracefully
26
  if img.size == 0 or img.ndim < 2:
27
- return 0, np.array([]), np.array([]), np.array([])
28
- img = img - img.mean()
29
- ac: np.ndarray = fftconvolve(img, np.flipud(np.fliplr(img)), mode='same')
30
- # check ac shape
 
31
  if ac.ndim < 1 or ac.shape[0] < 1:
32
- return img.shape[1], np.array([]), np.array([]), np.array([])
33
- ac_center_row = ac[int(ac.shape[0] / 2)]
34
- # check center row valid
 
35
  if ac_center_row.size == 0 or ac_center_row.std() == 0:
36
- return img.shape[1], np.array([]), np.array([]), np.array([])
37
 
 
38
  try:
39
- threshold = 3 * ac_center_row.std()
40
- median_val = np.median(ac_center_row)
41
- # threshold sanity check
42
- if not np.isfinite(threshold) or threshold <= 0:
43
- idx = np.array([])
44
- else:
45
- idx: np.ndarray = np.where(ac_center_row - median_val > threshold)[0]
46
  except (ValueError, FloatingPointError):
47
- idx = np.array([])
48
 
49
- # peak diffs for viz
50
  diffs: np.ndarray = np.ediff1d(idx)
51
- raw_offset: int = img.shape[1]
52
 
53
  if idx.size > 0:
54
- try:
55
- # skip center peak
56
- center_idx = len(ac_center_row) // 2
57
- valid_peaks_mask = np.abs(idx - center_idx) > 20
58
- if np.any(valid_peaks_mask):
59
- valid_peaks = idx[valid_peaks_mask]
60
- valid_peaks_values = ac_center_row[valid_peaks]
61
- if valid_peaks.size > 0:
62
- # highest peak
63
- highest_peak_idx = valid_peaks[np.argmax(valid_peaks_values)]
64
- raw_offset = abs(highest_peak_idx - center_idx)
65
- # fallback to max diff if needed
66
- if diffs.size > 0:
67
- if raw_offset < 10 and np.max(diffs) >= 10:
68
- raw_offset = np.max(diffs)
69
- elif raw_offset < 10 and np.max(diffs) < 10:
70
- raw_offset = img.shape[1]
71
- except (ValueError, IndexError):
72
- raw_offset = img.shape[1]
73
-
74
- # min offset constraint
75
- if img.shape[1] < 10:
76
- final_offset = max(1, min(raw_offset, img.shape[1]))
77
- else:
78
- final_offset = max(10, min(raw_offset, img.shape[1]))
79
 
80
  return final_offset, ac_center_row, idx, diffs
81
 
@@ -158,6 +160,32 @@ class InteractiveSolver:
158
  self.autocorrelation_peak_indices: np.ndarray = np.array([])
159
  self.autocorrelation_peak_diffs: np.ndarray = np.array([])
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  def solve_with_offset(self, user_offset: int, channel_mode: str = 'separate') -> np.ndarray:
162
  """
163
  Solves the autostereogram using a specified offset and channel handling mode.
@@ -172,60 +200,45 @@ class InteractiveSolver:
172
  The solved image as a NumPy array.
173
  """
174
  if user_offset <= 0:
175
- if channel_mode == 'average':
176
- return np.zeros((self.m, self.n), dtype=float)
177
- else:
178
- return np.zeros((self.m, self.n * self.c), dtype=float)
179
-
180
- int_gap = int(user_offset)
181
- effective_gap = min(int_gap, self.n)
 
182
  final_width_per_channel = max(0, self.n - effective_gap)
183
 
184
  if channel_mode == 'average':
185
  img_to_process = np.mean(self.image, axis=2) if self.color_image else self.image
186
- if img_to_process.size == 0 or img_to_process.std() == 0.0:
187
- return np.zeros((self.m, final_width_per_channel), dtype=float)
188
- shifted: np.ndarray = shift_pic(img_to_process, effective_gap)
189
- if shifted.size == 0:
190
- return np.zeros((self.m, 0), dtype=float)
191
- try:
192
- filt_1: np.ndarray = ndimage.prewitt(shifted)
193
- filt_2: np.ndarray = ndimage.uniform_filter(filt_1, size=(5, 5))
194
- if _SKIMAGE_AVAILABLE:
195
- filt_2 = post_process(filt_2)
196
- return filt_2
197
- except Exception:
198
- return np.zeros((self.m, final_width_per_channel), dtype=float)
199
 
200
  elif channel_mode == 'separate':
201
- solution: np.ndarray = np.zeros((self.m, final_width_per_channel * self.c), dtype=float)
 
 
 
 
 
 
 
202
  for i in range(self.c):
203
- color: np.ndarray = self.image[:, :, i] if self.color_image else self.image
204
- if not self.color_image and i > 0:
205
- break
206
- if color.size == 0 or color.std() == 0.0:
207
- continue
208
- shifted: np.ndarray = shift_pic(color, effective_gap)
209
- if shifted.size == 0:
210
- continue
211
- try:
212
- filt_1: np.ndarray = ndimage.prewitt(shifted)
213
- filt_2: np.ndarray = ndimage.uniform_filter(filt_1, size=(5, 5))
214
- if _SKIMAGE_AVAILABLE:
215
- filt_2 = post_process(filt_2)
216
- filt_m, filt_n = filt_2.shape
217
- if filt_n != final_width_per_channel:
218
- if filt_n < final_width_per_channel:
219
- continue
220
- filt_2 = filt_2[:, :final_width_per_channel]
221
- filt_n = final_width_per_channel
222
  start_col = i * final_width_per_channel
223
  end_col = start_col + final_width_per_channel
224
- rows_to_copy = min(self.m, filt_m)
225
- solution[:rows_to_copy, start_col:end_col] = filt_2[:rows_to_copy, :]
226
- except Exception:
227
- continue
228
  return solution
229
 
230
- else:
 
231
  return np.zeros((self.m, final_width_per_channel * self.c), dtype=float)
 
17
 
18
  from scipy.signal import fftconvolve
19
 
20
+ # constants
21
+ MIN_OFFSET_THRESHOLD = 10 # min offset for images wider than this
22
+ MIN_PEAK_DISTANCE_FROM_CENTER = 20 # min distance of a peak from the center to be considered valid
23
+ AUTOCORRELATION_STD_FACTOR = 3 # factor for threshold calculation in autocorrelation
24
+
25
  def offset(img: np.ndarray) -> Tuple[int, np.ndarray, np.ndarray, np.ndarray]:
26
  """
27
  Calculates the offset that defines the stereoscopic effect.
28
+ Selects the offset corresponding to the highest peak in the autocorrelation curve.
29
  """
 
30
  if img.size == 0 or img.ndim < 2:
31
+ return 0, np.array([]), np.array([]), np.array([]) # return 0 for invalid input
32
+
33
+ img_mean_subtracted = img - img.mean()
34
+ ac: np.ndarray = fftconvolve(img_mean_subtracted, np.flipud(np.fliplr(img_mean_subtracted)), mode='same')
35
+
36
  if ac.ndim < 1 or ac.shape[0] < 1:
37
+ return img.shape[1], np.array([]), np.array([]), np.array([]) # fallback to image width
38
+
39
+ ac_center_row = ac[ac.shape[0] // 2]
40
+
41
  if ac_center_row.size == 0 or ac_center_row.std() == 0:
42
+ return img.shape[1], np.array([]), np.array([]), np.array([]) # fallback if center row is invalid
43
 
44
+ idx = np.array([])
45
  try:
46
+ std_dev = ac_center_row.std()
47
+ if std_dev > 0: # ensure std_dev is positive
48
+ threshold = AUTOCORRELATION_STD_FACTOR * std_dev
49
+ median_val = np.median(ac_center_row)
50
+ if np.isfinite(threshold): # ensure threshold is a valid number
51
+ idx = np.where(ac_center_row - median_val > threshold)[0]
 
52
  except (ValueError, FloatingPointError):
53
+ pass # idx remains empty if calculation fails
54
 
 
55
  diffs: np.ndarray = np.ediff1d(idx)
56
+ raw_offset: int = img.shape[1] # default to image width
57
 
58
  if idx.size > 0:
59
+ center_idx = len(ac_center_row) // 2
60
+ # consider peaks sufficiently far from the center
61
+ valid_peaks_mask = np.abs(idx - center_idx) > MIN_PEAK_DISTANCE_FROM_CENTER
62
+
63
+ if np.any(valid_peaks_mask):
64
+ valid_peaks = idx[valid_peaks_mask]
65
+ valid_peaks_values = ac_center_row[valid_peaks]
66
+ if valid_peaks.size > 0:
67
+ # offset from the highest valid peak
68
+ highest_peak_idx = valid_peaks[np.argmax(valid_peaks_values)]
69
+ raw_offset = abs(highest_peak_idx - center_idx)
70
+
71
+ # fallback to max difference between peaks if current raw_offset is too small
72
+ if diffs.size > 0 and raw_offset < MIN_OFFSET_THRESHOLD:
73
+ max_diff = np.max(diffs)
74
+ if max_diff >= MIN_OFFSET_THRESHOLD:
75
+ raw_offset = max_diff
76
+ # if max_diff is also small, it implies no strong periodic pattern, keep img.shape[1] as fallback
77
+
78
+ # apply min offset constraint based on image width
79
+ min_practical_offset = MIN_OFFSET_THRESHOLD if img.shape[1] >= MIN_OFFSET_THRESHOLD else 1
80
+ final_offset = max(min_practical_offset, min(raw_offset, img.shape[1]))
 
 
 
81
 
82
  return final_offset, ac_center_row, idx, diffs
83
 
 
160
  self.autocorrelation_peak_indices: np.ndarray = np.array([])
161
  self.autocorrelation_peak_diffs: np.ndarray = np.array([])
162
 
163
+ def _process_single_channel(self, channel_data: np.ndarray, gap: int, target_width: int) -> np.ndarray:
164
+ """Helper to process a single image channel."""
165
+ if channel_data.size == 0 or channel_data.std() == 0.0:
166
+ return np.zeros((self.m, target_width), dtype=float)
167
+
168
+ shifted = shift_pic(channel_data, gap)
169
+ if shifted.size == 0 or shifted.shape[1] == 0: # check if shifted result is empty or has zero width
170
+ return np.zeros((self.m, target_width), dtype=float)
171
+
172
+ try:
173
+ filt_1 = ndimage.prewitt(shifted)
174
+ filt_2 = ndimage.uniform_filter(filt_1, size=(5, 5))
175
+ if _SKIMAGE_AVAILABLE:
176
+ filt_2 = post_process(filt_2)
177
+
178
+ # ensure correct width
179
+ if filt_2.shape[1] > target_width:
180
+ filt_2 = filt_2[:, :target_width]
181
+ elif filt_2.shape[1] < target_width:
182
+ # pad if narrower, though shift_pic should handle this by design
183
+ padding = np.zeros((filt_2.shape[0], target_width - filt_2.shape[1]), dtype=filt_2.dtype)
184
+ filt_2 = np.concatenate((filt_2, padding), axis=1)
185
+ return filt_2
186
+ except Exception:
187
+ return np.zeros((self.m, target_width), dtype=float)
188
+
189
  def solve_with_offset(self, user_offset: int, channel_mode: str = 'separate') -> np.ndarray:
190
  """
191
  Solves the autostereogram using a specified offset and channel handling mode.
 
200
  The solved image as a NumPy array.
201
  """
202
  if user_offset <= 0:
203
+ # determine expected shape for zero offset based on mode
204
+ # for average mode, it's single channel, original width
205
+ # for separate mode, it's multi-channel, original width per channel
206
+ # however, shift_pic with 0 gap returns original image, so width isn't reduced
207
+ # thus, n or n*c is appropriate.
208
+ return np.zeros((self.m, self.n if channel_mode == 'average' else self.n * self.c), dtype=float)
209
+
210
+ effective_gap = min(int(user_offset), self.n)
211
  final_width_per_channel = max(0, self.n - effective_gap)
212
 
213
  if channel_mode == 'average':
214
  img_to_process = np.mean(self.image, axis=2) if self.color_image else self.image
215
+ return self._process_single_channel(img_to_process, effective_gap, final_width_per_channel)
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
  elif channel_mode == 'separate':
218
+ # prepare an empty array for the full solution
219
+ # if final_width_per_channel is 0, the solution will also have 0 width for all channels
220
+ solution_shape = (self.m, final_width_per_channel * self.c)
221
+ solution: np.ndarray = np.zeros(solution_shape, dtype=float)
222
+
223
+ if final_width_per_channel == 0: # if no width, return empty solution early
224
+ return solution
225
+
226
  for i in range(self.c):
227
+ channel_data: np.ndarray = self.image[:, :, i] if self.color_image else self.image
228
+ if not self.color_image and i > 0: # only process once for grayscale
229
+ break
230
+
231
+ processed_channel = self._process_single_channel(channel_data, effective_gap, final_width_per_channel)
232
+
233
+ # ensure processed_channel has the correct dimensions before assignment
234
+ if processed_channel.shape[0] == self.m and processed_channel.shape[1] == final_width_per_channel:
 
 
 
 
 
 
 
 
 
 
 
235
  start_col = i * final_width_per_channel
236
  end_col = start_col + final_width_per_channel
237
+ solution[:, start_col:end_col] = processed_channel
238
+ # if dimensions mismatch (e.g. due to an error in _process_single_channel returning wrong shape),
239
+ # that part of the solution remains zeros, which is a graceful fallback.
 
240
  return solution
241
 
242
+ else: # unknown channel_mode
243
+ # fallback to a zero array with expected dimensions if mode is invalid
244
  return np.zeros((self.m, final_width_per_channel * self.c), dtype=float)