Total Complexity | 70 |
Total Lines | 473 |
Duplicated Lines | 7.4 % |
Changes | 0 |
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like savu.plugins.centering.vo_centering_iterative often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
1 | # Copyright 2014 Diamond Light Source Ltd. |
||
2 | # |
||
3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
||
4 | # you may not use this file except in compliance with the License. |
||
5 | # You may obtain a copy of the License at |
||
6 | # |
||
7 | # http://www.apache.org/licenses/LICENSE-2.0 |
||
8 | # |
||
9 | # Unless required by applicable law or agreed to in writing, software |
||
10 | # distributed under the License is distributed on an "AS IS" BASIS, |
||
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||
12 | # See the License for the specific language governing permissions and |
||
13 | # limitations under the License. |
||
14 | """ |
||
15 | .. module:: vo_centering_iterative |
||
16 | :platform: Unix |
||
17 | :synopsis: A plugin to find the center of rotation per frame |
||
18 | .. moduleauthor:: Mark Basham <[email protected]> |
||
19 | """ |
||
20 | |||
21 | import math |
||
22 | import logging |
||
23 | import numpy as np |
||
24 | import scipy.ndimage as ndi |
||
25 | import scipy.ndimage.filters as filter |
||
26 | import pyfftw.interfaces.scipy_fftpack as fft |
||
27 | |||
28 | from scipy import signal |
||
29 | |||
30 | from savu.plugins.utils import register_plugin |
||
31 | from savu.data.plugin_list import CitationInformation |
||
32 | from savu.plugins.filters.base_filter import BaseFilter |
||
33 | from savu.plugins.driver.iterative_plugin import IterativePlugin |
||
34 | |||
35 | # :u*param search_area: Search area in pixels from horizontal approximate \ |
||
36 | # centre of the image. Default: (-50, 50). |
||
37 | # Deprecated !!! |
||
38 | |||
39 | @register_plugin |
||
40 | class VoCenteringIterative(BaseFilter, IterativePlugin): |
||
41 | """ |
||
42 | A plugin to calculate the centre of rotation using the Vo Method |
||
43 | |||
44 | :param ratio: The ratio between the size of object and FOV of \ |
||
45 | the camera. Default: 0.5. |
||
46 | :param row_drop: Drop lines around vertical center of the \ |
||
47 | mask. Default: 20. |
||
48 | :param search_radius: Use for fine searching. Default: 6. |
||
49 | :param step: Step of fine searching. Default: 0.5. |
||
50 | :param expand_by: The number of pixels to expand the search region by \ |
||
51 | on each iteration. Default: 5 |
||
52 | :param boundary_distance: Accepted distance of minima from the boundary of\ |
||
53 | the listshift in the coarse search. Default: 3. |
||
54 | :u*param preview: A slice list of required frames (sinograms) to use in \ |
||
55 | the calulation of the centre of rotation (this will not reduce the data \ |
||
56 | size for subsequent plugins). Default: []. |
||
57 | :param datasets_to_populate: A list of datasets which require this \ |
||
58 | information. Default: []. |
||
59 | :param out_datasets: The default \ |
||
60 | names. Default: ['cor_raw','cor_fit', 'reliability']. |
||
61 | :u*param start_pixel: The approximate centre. If value is None, take the \ |
||
62 | value from .nxs file else set to image centre. Default: None. |
||
63 | """ |
||
64 | |||
65 | def __init__(self): |
||
66 | super(VoCenteringIterative, self).__init__("VoCenteringIterative") |
||
67 | self.search_area = (-20, 20) |
||
68 | self.peak_height_min = 50000 # arbitrary |
||
69 | self.min_dist = 3 # min distance deamed acceptible from boundary |
||
70 | self.expand_by = 5 # expand the search region by this amount |
||
71 | self.list_shift = None |
||
72 | self.warning_level = 0 |
||
73 | self.final = False |
||
74 | self.at_boundary = False |
||
75 | self.list_metric = [] |
||
76 | self.expand_direction = None |
||
77 | |||
78 | def _create_mask(self, Nrow, Ncol, obj_radius): |
||
79 | du, dv = 1.0/Ncol, (Nrow-1.0)/(Nrow*2.0*math.pi) |
||
80 | cen_row, cen_col = int(np.ceil(Nrow / 2.0)-1), int(np.ceil(Ncol / 2.0)-1) |
||
81 | drop = self.parameters['row_drop'] |
||
82 | mask = np.zeros((Nrow, Ncol), dtype=np.float32) |
||
83 | for i in range(Nrow): |
||
84 | num1 = np.round(((i-cen_row)*dv/obj_radius)/du) |
||
85 | p1, p2 = (np.clip(np.sort((-num1+cen_col, num1+cen_col)), |
||
86 | 0, Ncol-1)).astype(int) |
||
87 | mask[i, p1:p2+1] = np.ones(p2-p1+1, dtype=np.float32) |
||
88 | |||
89 | if drop < cen_row: |
||
90 | mask[cen_row-drop:cen_row+drop+1, :] = \ |
||
91 | np.zeros((2*drop + 1, Ncol), dtype=np.float32) |
||
92 | mask[:, cen_col-1:cen_col+2] = np.zeros((Nrow, 3), dtype=np.float32) |
||
93 | return mask |
||
94 | |||
95 | def _get_start_shift(self, centre): |
||
96 | if self.parameters['start_pixel'] is not None: |
||
97 | shift = centre - int(self.parameters['start_pixel']/self.downlevel) |
||
98 | else: |
||
99 | in_mData = self.get_in_meta_data()[0] |
||
100 | shift = centre - in_mData['centre'] if 'centre' in \ |
||
101 | list(in_mData.get_dictionary().keys()) else 0 |
||
102 | return int(shift) |
||
103 | |||
104 | def _coarse_search(self, sino, list_shift): |
||
105 | # search minsearch to maxsearch in 1 pixel steps |
||
106 | list_metric = np.zeros(len(list_shift), dtype=np.float32) |
||
107 | (Nrow, Ncol) = sino.shape |
||
108 | # check angles to determine if a sinogram should be chopped off. |
||
109 | # Copy the sinogram and flip left right, to make a full [0:2Pi] sino |
||
110 | sino2 = np.fliplr(sino[1:]) |
||
111 | # This image is used for compensating the shift of sino2 |
||
112 | compensateimage = np.zeros((Nrow-1, Ncol), dtype=np.float32) |
||
113 | # Start coarse search in which the shift step is 1 |
||
114 | compensateimage[:] = np.flipud(sino)[1:] |
||
115 | mask = self._create_mask(2*Nrow-1, Ncol, |
||
116 | 0.5*self.parameters['ratio']*Ncol) |
||
117 | count = 0 |
||
118 | for i in list_shift: |
||
119 | sino2a = np.roll(sino2, i, axis=1) |
||
120 | if i >= 0: |
||
121 | sino2a[:, 0:i] = compensateimage[:, 0:i] |
||
122 | else: |
||
123 | sino2a[:, i:] = compensateimage[:, i:] |
||
124 | list_metric[count] = np.sum( |
||
125 | np.abs(fft.fftshift(fft.fft2(np.vstack((sino, sino2a)))))*mask) |
||
126 | count += 1 |
||
127 | return list_metric |
||
128 | |||
129 | def _fine_search(self, sino, raw_cor): |
||
130 | (Nrow, Ncol) = sino.shape |
||
131 | centerfliplr = (Ncol + 1.0) / 2.0 - 1.0 |
||
132 | # Use to shift the sino2 to the raw CoR |
||
133 | shiftsino = np.int16(2*(raw_cor-centerfliplr)) |
||
134 | sino2 = np.roll(np.fliplr(sino[1:]), shiftsino, axis=1) |
||
135 | lefttake = 0 |
||
136 | righttake = Ncol-1 |
||
137 | search_rad = self.parameters['search_radius'] |
||
138 | |||
139 | if raw_cor <= centerfliplr: |
||
140 | lefttake = np.int16(np.ceil(search_rad+1)) |
||
141 | righttake = np.int16(np.floor(2*raw_cor-search_rad-1)) |
||
142 | else: |
||
143 | lefttake = np.int16(np.ceil(raw_cor-(Ncol-1-raw_cor)+search_rad+1)) |
||
144 | righttake = np.int16(np.floor(Ncol-1-search_rad-1)) |
||
145 | |||
146 | Ncol1 = righttake-lefttake + 1 |
||
147 | mask = self._create_mask(2*Nrow-1, Ncol1, |
||
148 | 0.5*self.parameters['ratio']*Ncol) |
||
149 | numshift = np.int16((2*search_rad)/self.parameters['step'])+1 |
||
150 | listshift = np.linspace(-search_rad, search_rad, num=numshift) |
||
151 | listmetric = np.zeros(len(listshift), dtype=np.float32) |
||
152 | num1 = 0 |
||
153 | factor1 = np.mean(sino[-1, lefttake:righttake]) |
||
154 | for i in listshift: |
||
155 | sino2a = ndi.interpolation.shift(sino2, (0, i), prefilter=False) |
||
156 | factor2 = np.mean(sino2a[0, lefttake:righttake]) |
||
157 | sino2a = sino2a*factor1/factor2 |
||
158 | sinojoin = np.vstack((sino, sino2a)) |
||
159 | listmetric[num1] = np.sum(np.abs(fft.fftshift( |
||
160 | fft.fft2(sinojoin[:, lefttake:righttake + 1])))*mask) |
||
161 | num1 = num1 + 1 |
||
162 | minpos = np.argmin(listmetric) |
||
163 | rotcenter = raw_cor + listshift[minpos] / 2.0 |
||
164 | return rotcenter |
||
165 | |||
166 | def _get_listshift(self): |
||
167 | smin, smax = self.search_area if self.get_iteration() == 0 \ |
||
168 | else self._expand_search() |
||
169 | list_shift = np.arange(smin, smax+2, 2) - self.start_shift |
||
170 | logging.debug('list shift is %s', list_shift) |
||
171 | return list_shift |
||
172 | |||
173 | def _expand_search(self): |
||
174 | if self.expand_direction == 'left': |
||
175 | return self._expand_left() |
||
176 | elif self.expand_direction == 'right': |
||
177 | return self._expand_right() |
||
178 | else: |
||
179 | raise Exception('Unknown expand direction.') |
||
180 | |||
181 | def _expand_left(self): |
||
182 | smax = self.list_shift[0] - 2 |
||
183 | smin = smax - self.expand_by*2 |
||
184 | |||
185 | if smin <= -self.boundary: |
||
186 | smin = -self.boundary |
||
187 | self.at_boundary = True |
||
188 | return smin, smax |
||
189 | |||
190 | def _expand_right(self): |
||
191 | smin = self.list_shift[-1] + 2 |
||
192 | smax = self.list_shift[-1] + self.expand_by*2 |
||
193 | |||
194 | if smax <= self.boundary: |
||
195 | smax = self.boundary |
||
196 | self.at_boundary = True |
||
197 | |||
198 | return smin, smax |
||
199 | |||
200 | def pre_process(self): |
||
201 | pData = self.get_plugin_in_datasets()[0] |
||
202 | label = pData.get_data_dimension_by_axis_label |
||
203 | Ncol = pData.get_shape()[label('detector_x')] |
||
204 | self.downlevel = 4 if Ncol > 1800 else 1 |
||
205 | self.downsample = slice(0, Ncol, self.downlevel) |
||
206 | Ncol_downsample = len(np.arange(0, Ncol, self.downlevel)) |
||
207 | self.centre_fliplr = (Ncol_downsample - 1.0) / 2.0 |
||
208 | self.start_shift = self._get_start_shift(self.centre_fliplr)*2 |
||
209 | self.boundary = int(np.ceil(Ncol/4.0)) |
||
210 | |||
211 | def process_frames(self, data): |
||
212 | if not self.final: |
||
213 | logging.debug('performing coarse search for iteration %s', |
||
214 | self.get_iteration()) |
||
215 | sino = filter.gaussian_filter(data[0][:, self.downsample], (3, 1)) |
||
216 | list_shift = self._get_listshift() |
||
217 | list_metric = self._coarse_search(sino, list_shift) |
||
218 | self._update_lists(list(list_shift), list(list_metric)) |
||
219 | |||
220 | self.coarse_cor, dist, reliability_metrics = \ |
||
221 | self._analyse_result(self.list_metric, self.list_shift) |
||
222 | |||
223 | return [np.array([self.coarse_cor]), np.array([dist]), |
||
224 | np.array([reliability_metrics]), np.array([self.list_metric])] |
||
225 | else: |
||
226 | logging.debug("performing fine search") |
||
227 | sino = filter.median_filter(data[0], (2, 2)) |
||
228 | cor = self._fine_search(sino, self.coarse_cor) |
||
229 | self.set_processing_complete() |
||
230 | return [np.array([cor]), np.array([self.list_metric])] |
||
231 | |||
232 | def _update_lists(self, shift, metric): |
||
233 | if self.expand_direction == 'left': |
||
234 | self.list_shift = shift + self.list_shift |
||
235 | self.list_metric = metric + self.list_metric |
||
236 | elif self.expand_direction == 'right': |
||
237 | self.list_shift += shift |
||
238 | self.list_metric += metric |
||
239 | else: |
||
240 | self.list_shift = shift |
||
241 | self.list_metric = metric |
||
242 | |||
243 | def _analyse_result(self, metric, shift): |
||
244 | minpos = np.argmin(metric) |
||
245 | dist = min(abs(len(shift) - minpos), -minpos) |
||
246 | |||
247 | rot_centre = (self.centre_fliplr + shift[minpos] / 2.0)*self.downlevel |
||
248 | peaks = self._find_peaks(metric) |
||
249 | |||
250 | good_nPeaks = True |
||
251 | if len(peaks) != 1: |
||
252 | good_nPeaks = False |
||
253 | good_peak_height = True if np.any(peaks) and \ |
||
254 | max(peaks) > self.peak_height_min else False |
||
255 | |||
256 | metric = 0.0 |
||
257 | if (good_peak_height and good_nPeaks): |
||
258 | metric = 1.0 |
||
259 | elif (good_peak_height or good_nPeaks): |
||
260 | metric = 0.5 |
||
261 | |||
262 | return rot_centre, dist, metric |
||
263 | |||
264 | def _find_peaks(self, metric): |
||
265 | import peakutils |
||
266 | grad2 = np.gradient(np.gradient(metric)) |
||
267 | grad2[grad2 < 0] = 0 |
||
268 | index = peakutils.indexes(grad2, thres=0.5, min_dist=3) |
||
269 | return np.sort(grad2[index]) |
||
270 | |||
271 | def post_process(self): |
||
272 | logging.debug("in the post process function") |
||
273 | in_datasets, out_datasets = self.get_datasets() |
||
274 | |||
275 | # ===================================================================== |
||
276 | # Analyse distance of centre values from boundary of search region |
||
277 | dist_from_boundary = np.squeeze(out_datasets[1].data[...]) |
||
278 | near_boundary = np.where(abs(dist_from_boundary) < self.min_dist)[0] |
||
279 | nEntries = len(dist_from_boundary) |
||
280 | |||
281 | # Case1: Greater than half the results are near the boundary |
||
282 | if (len(near_boundary)/float(nEntries)) > 0.5: |
||
283 | # find which boundary |
||
284 | signs = np.sign(dist_from_boundary[near_boundary]) |
||
285 | left, right = len(signs[signs < 0]), len(signs[signs > 0]) |
||
286 | |||
287 | logging.debug("res: results are near boundary") |
||
288 | if not self.at_boundary: |
||
289 | # if they are all at the same boundary expand the search region |
||
290 | if not (left and right): |
||
291 | logging.debug("res: expanding") |
||
292 | self.expand_direction = 'left' if left else 'right' |
||
293 | # if they are at different boundaries determine which values |
||
294 | # are most reliable |
||
295 | else: |
||
296 | logging.debug("res: choosing a boundary") |
||
297 | self.expand_direction = \ |
||
298 | self._choose_boundary(near_boundary, signs) |
||
299 | # case that the results are close to different boundaries |
||
300 | # Analyse reliability and choose direction |
||
301 | else: |
||
302 | logging.debug("res: at the edge of the boundary") |
||
303 | # Move on to the fine search |
||
304 | self._set_final_process() |
||
305 | self.warning_level = 1 # change this to be more descriptive *** |
||
306 | else: |
||
307 | logging.debug("result is not near the boundary") |
||
308 | # Move on to the fine search |
||
309 | self._set_final_process() |
||
310 | # ===================================================================== |
||
311 | |||
312 | def _choose_boundary(self, idx, signs): |
||
313 | good, maybe, bad = self._get_reliability_levels() |
||
314 | sign = self._check_entries(good, signs[good]) |
||
315 | self.warning_level = 0 |
||
316 | if not sign: |
||
317 | sign = self._check_entries(maybe, signs[maybe]) |
||
318 | self.warning_level = 1 |
||
319 | if not sign: |
||
320 | sign = self._check_entries(bad, signs[bad]) |
||
321 | self.warning_level = 2 |
||
322 | return sign |
||
323 | |||
324 | def _check_entries(self, idx, signs): |
||
325 | if np.any(idx): |
||
326 | left, right = signs[signs < 0], signs[signs > 0] |
||
327 | if not (left and right): |
||
328 | # use all the good ones |
||
329 | return 'left' if left else 'right' |
||
330 | return None |
||
331 | |||
332 | def _get_reliability_levels(self, final=False): |
||
333 | in_datasets, out_datasets = \ |
||
334 | self.get_datasets() if not final else self.get_original_datasets() |
||
335 | reliability = np.squeeze(out_datasets[2].data[...]) |
||
336 | logging.debug('reliability is %s', reliability) |
||
337 | good = np.where(reliability == 1.0)[0] |
||
338 | maybe = np.where(reliability == 0.5)[0] |
||
339 | bad = np.where(reliability == 0.0)[0] |
||
340 | return good, maybe, bad |
||
341 | |||
342 | def final_post_process(self): |
||
343 | |||
344 | # choose which values to include |
||
345 | good, maybe, bad = self._get_reliability_levels(final=True) |
||
346 | # Do I need to change the warning levels here? |
||
347 | entries = good if np.any(good) else maybe if np.any(maybe) else bad |
||
348 | self.warning_level = 0 if np.any(good) else 1 if np.any(maybe) else 2 |
||
349 | logging.debug('sinograms used in final calculations are %s', entries) |
||
350 | |||
351 | # do some curve fitting here |
||
352 | # Get a handle on the original datasets |
||
353 | in_dataset, out_dataset = self.get_original_datasets() |
||
354 | cor_raw = np.squeeze(out_dataset[0].data[...])[entries] |
||
355 | cor_fit = out_dataset[1].data[...] |
||
356 | fit = np.zeros(cor_fit.shape) |
||
357 | fit[:] = np.median(cor_raw) |
||
358 | cor_fit = fit |
||
359 | out_dataset[1].data[:] = cor_fit[:] |
||
360 | |||
361 | self.populate_meta_data('cor_raw', cor_raw) |
||
362 | self.populate_meta_data('centre_of_rotation', |
||
363 | out_dataset[1].data[:].squeeze(axis=1)) |
||
364 | |||
365 | def _set_final_process(self): |
||
366 | self.final = True |
||
367 | self.post_process = self.final_post_process |
||
368 | in_dataset, out_dataset = self.get_datasets() |
||
369 | self.set_iteration_datasets( |
||
370 | self.get_iteration()+1, [in_dataset[0]], [out_dataset[0]]) |
||
371 | |||
372 | def populate_meta_data(self, key, value): |
||
373 | datasets = self.parameters['datasets_to_populate'] |
||
374 | in_meta_data = self.get_in_meta_data()[0] |
||
375 | in_meta_data.set(key, value) |
||
376 | for name in datasets: |
||
377 | self.exp.index['in_data'][name].meta_data.set(key, value) |
||
378 | |||
379 | def setup(self): |
||
380 | # set up the output dataset that is created by the plugin |
||
381 | in_dataset, out_dataset = self.get_datasets() |
||
382 | |||
383 | self.orig_full_shape = in_dataset[0].get_shape() |
||
384 | |||
385 | # reduce the data as per data_subset parameter |
||
386 | self.set_preview(in_dataset[0], self.parameters['preview']) |
||
387 | |||
388 | in_pData, out_pData = self.get_plugin_datasets() |
||
389 | in_pData[0].plugin_data_setup('SINOGRAM', self.get_max_frames()) |
||
390 | # copy all required information from in_dataset[0] |
||
391 | fullData = in_dataset[0] |
||
392 | |||
393 | slice_dirs = np.array(in_dataset[0].get_slice_dimensions()) |
||
394 | new_shape = (np.prod(np.array(fullData.get_shape())[slice_dirs]), 1) |
||
395 | self.orig_shape = \ |
||
396 | (np.prod(np.array(self.orig_full_shape)[slice_dirs]), 1) |
||
397 | |||
398 | self._create_metadata_dataset(out_dataset[0], new_shape) |
||
399 | self._create_metadata_dataset(out_dataset[1], self.orig_shape) |
||
400 | self._create_metadata_dataset(out_dataset[2], new_shape) |
||
401 | |||
402 | # output metric |
||
403 | new_shape = (np.prod(np.array(fullData.get_shape())[slice_dirs]), 21) |
||
404 | self._create_metadata_dataset(out_dataset[3], new_shape) |
||
405 | |||
406 | out_pData[0].plugin_data_setup('METADATA', self.get_max_frames()) |
||
407 | out_pData[1].plugin_data_setup('METADATA', self.get_max_frames()) |
||
408 | out_pData[2].plugin_data_setup('METADATA', self.get_max_frames()) |
||
409 | out_pData[3].plugin_data_setup('METADATA', self.get_max_frames()) |
||
410 | |||
411 | def _create_metadata_dataset(self, data, shape): |
||
412 | data.create_dataset(shape=shape, |
||
413 | axis_labels=['x.pixels', 'y.pixels'], |
||
414 | remove=True, |
||
415 | transport='hdf5') |
||
416 | data.add_pattern("METADATA", core_dims=(1,), slice_dims=(0,)) |
||
417 | |||
418 | def nOutput_datasets(self): |
||
419 | return 4 |
||
420 | |||
421 | def get_max_frames(self): |
||
422 | return 'single' |
||
423 | |||
424 | def fix_transport(self): |
||
425 | # This plugin requires communication between processes in the post |
||
426 | # process, which it does via files |
||
427 | return 'hdf5' |
||
428 | |||
429 | def executive_summary(self): |
||
430 | if self.warning_level == 0: |
||
431 | msg = "Confidence in the centre value is high." |
||
432 | elif self.warning_level == 1: |
||
433 | msg = "Confidence in the centre value is average." |
||
434 | else: |
||
435 | msg = "Confidence in the centre value is low." |
||
436 | return [msg] |
||
437 | |||
438 | View Code Duplication | def get_citation_information(self): |
|
|
|||
439 | cite_info = CitationInformation() |
||
440 | cite_info.description = \ |
||
441 | ("The center of rotation for this reconstruction was calculated " + |
||
442 | "automatically using the method described in this work") |
||
443 | cite_info.bibtex = \ |
||
444 | ("@article{vo2014reliable,\n" + |
||
445 | "title={Reliable method for calculating the center of rotation " + |
||
446 | "in parallel-beam tomography},\n" + |
||
447 | "author={Vo, Nghia T and Drakopoulos, Michael and Atwood, " + |
||
448 | "Robert C and Reinhard, Christina},\n" + |
||
449 | "journal={Optics Express},\n" + |
||
450 | "volume={22},\n" + |
||
451 | "number={16},\n" + |
||
452 | "pages={19078--19086},\n" + |
||
453 | "year={2014},\n" + |
||
454 | "publisher={Optical Society of America}\n" + |
||
455 | "}") |
||
456 | cite_info.endnote = \ |
||
457 | ("%0 Journal Article\n" + |
||
458 | "%T Reliable method for calculating the center of rotation in " + |
||
459 | "parallel-beam tomography\n" + |
||
460 | "%A Vo, Nghia T\n" + |
||
461 | "%A Drakopoulos, Michael\n" + |
||
462 | "%A Atwood, Robert C\n" + |
||
463 | "%A Reinhard, Christina\n" + |
||
464 | "%J Optics Express\n" + |
||
465 | "%V 22\n" + |
||
466 | "%N 16\n" + |
||
467 | "%P 19078-19086\n" + |
||
468 | "%@ 1094-4087\n" + |
||
469 | "%D 2014\n" + |
||
470 | "%I Optical Society of America") |
||
471 | cite_info.doi = "https://doi.org/10.1364/OE.22.019078" |
||
472 | return cite_info |
||
473 |