Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/dolphin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from ._log import *
from ._show_versions import *
from ._types import *
from .goldstein import goldstein
97 changes: 97 additions & 0 deletions src/dolphin/goldstein.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import numpy as np
from numpy.typing import ArrayLike


def goldstein(phase: ArrayLike, alpha: float, psize: int = 32) -> np.ndarray:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming the input argument "phase" might be a little misleading. The Goldstein filter is intended to operate on complex-valued interferogram data -- not on the wrapped phase directly.

I typically name this parameter "igram".

Suggested change
def goldstein(phase: ArrayLike, alpha: float, psize: int = 32) -> np.ndarray:
def goldstein(igram: ArrayLike, alpha: float, psize: int = 32) -> np.ndarray:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I lost this same comment in a closed window- let's definitely change it for the next PR because it confused me several times

"""Apply the Goldstein adaptive filter to the given data.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a reference to the Goldstein/Werner 1998 paper here.

@scottstanie has made it really easy to cite references in docstrings in this repo--

You could add a bibtex entry like this

@article{Goldstein1998RadarInterferogramFiltering,
    title = {Radar interferogram filtering for geophysical applications},
    author = {Goldstein, Richard M. and Werner, Charles L.},
    year = {1998},
    month = nov,
    journal = {Geophysical Research Letters},
    volume = {25},
    number = {21},
    pages = {4035–4038}
}

to this file: docs/references.bib

Then you could cite the paper in a docstring like this:

Suggested change
"""Apply the Goldstein adaptive filter to the given data.
"""Apply the Goldstein adaptive filter [@Goldstein1998RadarInterferogramFiltering] to the given data.

Parameters
----------
phase : np.ndarray
Copy link
Member

@gmgunter gmgunter Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The annotation above says this argument is of type ArrayLike. Usually types like this are documented like this in the NumPy docs:

Suggested change
phase : np.ndarray
phase : array_like

where "array_like" is a keyword in the NumPy glossary: https://numpy.org/doc/stable/glossary.html#term-array_like

2D complex array containing the data to be filtered.
alpha : float
Filtering parameter for Goldstein algorithm
Must be between 0 (no filtering) and 1 (maximum filtering)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must be between 0 (no filtering) and 1 (maximum filtering)

Should we enforce this in the function body? E.g. something like this:

if (alpha < 0.0) or (alpha > 1.0):
    raise ValueError(f"alpha must be between 0 and 1, instead got {alpha}")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, you could consider adding a check that psize has a valid value.

I think psize should be at least >=2, since if psize is 1, then the block stride (psize // 2) will be 0.

psize : int, optional
edge length of square patch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edge length of square patch

This description might not mean much to a user who's unfamiliar with the details of the implementation.

We could maybe make the intention of psize more clear by including a brief explanation of the blocking logic in the function description for context, e.g.

     """Apply the Goldstein adaptive filter to the given data.
+
+    Applies an adaptive spectral filter to fixed-size overlapping square patches
+    of the input data. The contributions from multiple overlapping patches are
+    blended using a triangular weighting of the filtered data in order to
+    attenuate boundary artifacts.

Default = 32
Comment on lines +13 to +17
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to use full sentences in docstring descriptions (with periods and proper capitalization), since I think it looks more professional.

Suggested change
Filtering parameter for Goldstein algorithm
Must be between 0 (no filtering) and 1 (maximum filtering)
psize : int, optional
edge length of square patch
Default = 32
Filtering parameter for the Goldstein algorithm.
Must be between 0 (no filtering) and 1 (maximum filtering).
psize : int, optional
Edge length of square patch. Defaults to 32.

Returns
-------
2D numpy array of filtered data.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should include the type of the return value here as well to be consistent with NumPy docstring conventions

Suggested change
2D numpy array of filtered data.
numpy.ndarray
2D numpy array of filtered data.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use proper capitalization of other projects' names when referring to them in our docstrings.

"numpy" is officially stylized as "NumPy"

Suggested change
2D numpy array of filtered data.
2D NumPy array of filtered data.

Or alternatively you could just remove the "numpy" part and say "2D array"

Suggested change
2D numpy array of filtered data.
2D array of filtered data.

"""

def apply_pspec(data: ArrayLike):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def apply_pspec(data: ArrayLike):
def apply_pspec(data: ArrayLike) -> np.ndarray:

# NaN is allowed value
assert not (alpha < 0), f"Invalid parameter value {alpha} < 0"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NaN is allowed value

Is this comment relevant to the code that it's attached to? Are we saying that alpha is allowed to be NaN-valued?

(I think the behavior I had in mind was that the interferogram array could contain NaNs, but that alpha should always be finite-valued. I'm not sure that there'd be any reason to support NaN-valued alpha.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is an idiomatic use of assert.

By intention and common practice, assertions should be used for validating the programmer's own logic. They're sort of a tool for debugging and internally documenting assumptions. They shouldn't be used for validating external inputs to public functions. Instead, we should raise an exception of the appropriate type (like a ValueError) if we encounter bad inputs.

wgt = np.power(np.abs(data) ** 2, alpha / 2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is mathematically equivalent to

Suggested change
wgt = np.power(np.abs(data) ** 2, alpha / 2)
wgt = np.abs(data) ** alpha

and in fact that's actually what the reference paper describes

image

So let's do that instead since it's simpler and more computationally efficient.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we care about the amplitude values of the filtered data at all? Maybe a question for @scottstanie.

Currently, the filter coefficients and block weights are not normalized, so the processing is effectively applying a scaling factor to the output. It shouldn't affect the phase, but the amplitude values will not be comparable to the input amplitudes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe so- snaphu just ignores the amplitude if you also pass the correlation, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I believe you're right about SNAPHU. The ICU algorithm does care of about interferogram amplitudes a bit (for computing "intensity neutrons"), but I don't think we're seriously considering ICU in our comparison of unwrappers. I'm not aware of other unwrapping algorithms that use amplitude information, so it seems reasonably safe to not worry about normalizing amplitudes.

data = wgt * data
return data

def make_wgt(nxp: int, nyp: int) -> np.ndarray:
# Create arrays of horizontal and vertical weights
wx = 1.0 - np.abs(np.arange(nxp // 2) - (nxp / 2.0 - 1.0)) / (nxp / 2.0 - 1.0)
wy = 1.0 - np.abs(np.arange(nyp // 2) - (nyp / 2.0 - 1.0)) / (nyp / 2.0 - 1.0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we intend to support odd-valued patch sizes (i.e. psize values)?

Note that the array of weights returned by this function won't have the same shape as each corresponding data block if nxp and/or nyp are odd-valued, since 2 * (N // 2) is not equal to N in the case where N is odd.

I personally think it'd be fine to not support odd-valued patch sizes, but IMO we should document this in the docstring and add a check that ensures psize is even-valued or else raises an exception with a more helpful error message than they will get currently.

Comment on lines +34 to +35
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case where nxp and nyp are both positive and even-valued, I think wx and wy are just linear ramps from 0 to 1.

So this should be functionally equivalent to the above (unless we want to support odd-valued psize):

Suggested change
wx = 1.0 - np.abs(np.arange(nxp // 2) - (nxp / 2.0 - 1.0)) / (nxp / 2.0 - 1.0)
wy = 1.0 - np.abs(np.arange(nyp // 2) - (nyp / 2.0 - 1.0)) / (nyp / 2.0 - 1.0)
wx = np.linspace(0.0, 1.0, num=nxp // 2)
wy = np.linspace(0.0, 1.0, num=nyp // 2)

# Compute the outer product of wx and wy to create
# the top-left quadrant of the weight matrix
quadrant = np.outer(wy, wx)
# Create a full weight matrix by mirroring the quadrant along both axes
wgt = np.block(
[
[quadrant, np.flip(quadrant, axis=1)],
[np.flip(quadrant, axis=0), np.flip(np.flip(quadrant, axis=0), axis=1)],
]
)
return wgt

def patch_goldstein_filter(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a compelling reason why we've defined this function inside the body of the goldstein() function (instead of defining it as its own free function)?

In some circumstances, I think this is a useful idiom for defining private functions, but in this case I think it might be fruitful to pull this function out. In particular, it would be useful to be able to test this function in isolation (without all of the blocking logic), but we currently can't do that since it's inaccessible to the unit test suite.

Copy link
Member

@gmgunter gmgunter Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit tests ☹️

If you make this a standalone function (so that its importable from the test suite), it should be pretty trivial to implement a test that checks that the phase values are unchanged by filtering with alpha equal to 0. I think that would be a nice test.

Another good test would be to simulate a signal with a known magnitude spectrum (e.g. by constructing the signal in the frequency domain and then taking the IFFT) and then applying patch_goldstein_filter() to it. Then you could compute the magnitude spectrum of the filtered data and compare it to the expected spectrum (which should be the original spectrum to the power 1+alpha).

It might also be worthwhile to exercise the blocking logic in a test. Common issues in block processing include off-by-one indexing errors and mishandling the remainder samples past the end of the last full-sized block. I think you could guard against most of these common issues in a test by passing an array of ones to the goldstein() function and checking that the output array values are constant (i.e. are all the same) to within some tolerance.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

related to the future nan change #261 , we could check that the number of input nodata values are similar/the same as the output nodata values.

data: ArrayLike, wgt: ArrayLike, psize: int
) -> np.ndarray:
"""Apply the filter to a single patch of data.
Parameters
----------
data : np.ndarray
2D complex array containing the data to be filtered.
wgt : np.ndarray
Comment on lines +55 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
data : np.ndarray
2D complex array containing the data to be filtered.
wgt : np.ndarray
data : array_like
2D complex array containing the data to be filtered.
wgt : array_like

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a side note, while import numpy as np is a common convention, I think it might be preferable to use the unabbreviated names of types in docstrings.

"np.ndarray" --> "numpy.ndarray"

weight matrix for summing neighboring data
psize : int
edge length of square FFT area
Returns
-------
2D numpy array of filtered data.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
2D numpy array of filtered data.
numpy.ndarray
2D array of filtered data.

"""
# Calculate alpha
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calculate alpha

This comment doesn't seem to be related to the code block that it's attached to.

data = np.fft.fft2(data, s=(psize, psize))
data = apply_pspec(data)
data = np.fft.ifft2(data, s=(psize, psize))
return wgt * data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return wgt * data

From the perspective of breaking up the algorithm into functions that each perform logically independent steps, it seems to me that this function shouldn't know anything about wgt.

If I understand what's going on correctly, wgt is a matrix of weights that are applied to each processing block in order to "feather" or blend the results of multiple overlapping blocks. In other words, I think wgt is an implementation detail of the overlapping block processing. But patch_goldstein_filter() is applying the filter to a single block of data -- ideally it should be independent of the blocking logic.

Instead, I would suggest multiplying data by wgt outside of this function.


def apply_goldstein_filter(data: ArrayLike) -> np.ndarray:
# Create an empty array for the output
out = np.zeros(data.shape, dtype=np.complex64)
# ignore processing for empty chunks
if np.all(np.isnan(data)):
return data
Comment on lines +76 to +78
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need any special handling for this case?

Let's say hypothetically we remove lines 77 & 78. In that case, if the input data contained all NaN values, I expect that the output filtered data will still contain all NaNs. So lines 77 & 78 are just optimizing that case -- the results should still be the same but we get to skip some unnecessary computation.

But should we really be optimizing for the case where the input data is all NaN-valued? Note that this is (slightly) pessimizing the usual case where the input contains at least one non-NaN value, since np.all(np.isnan(data)) takes relatively small but nonzero time.

I would suggest removing these lines since I think encountering all-NaN-valued datasets seems unlikely in normal operation so, on net, this is probably a pessimization.

Suggested change
# ignore processing for empty chunks
if np.all(np.isnan(data)):
return data

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a good thing to test- For the stitched, geocoded interferograms, The tilt means it's about 30% nodata. For single bursts, it's about 60% nodata (which is why adding a check like this speeds up the wrapped phase part by ~2x).

On the other hand, having this hardcoded is very geocoded-input-centric. An RIFG has no need for this and only gets it slowed down

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah will remove this. I think this is a remnant from when I was nanning out zero values, which can't be done with tophu at the moment anyway.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the stitched, geocoded interferograms, The tilt means it's about 30% nodata. For single bursts, it's about 60% nodata (which is why adding a check like this speeds up the wrapped phase part by ~2x).

Right now, the check for all NaN values is outside the loop over patches. So this shortcut will only trigger if the dataset contains 100% nodata. IIUC, there shouldn't be any speed up for interferograms that contain 60% nodata.

Maybe we should consider adding a similar check inside the loop over patches? If an individual patch contains all NaNs, there's no need to compute & apply the filter there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh right, yes this should be removed then, the comment # ignore processing for empty chunks fooled me

# Create the weight matrix
wgt_matrix = make_wgt(psize, psize)
# Iterate over windows of the data
for i in range(0, data.shape[0] - psize, psize // 2):
for j in range(0, data.shape[1] - psize, psize // 2):
Comment on lines +82 to +83
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting another potential issue with odd-valued psize here--

If psize is even-valued, then each pixel will be covered by exactly 4 overlapping blocks (except for pixels near the borders of the data array.

But if psize is odd-valued, then the last row & column of pixels in each block will each be covered by an additional block.

# Create proocessing windows
data_window = data[i : i + psize, j : j + psize]
wgt_window = wgt_matrix[: data_window.shape[0], : data_window.shape[1]]
# Apply the filter to the window
filtered_window = patch_goldstein_filter(data_window, wgt_window, psize)
# Add the result to the output array
slice_i = slice(i, min(i + psize, out.shape[0]))
slice_j = slice(j, min(j + psize, out.shape[1]))
out[slice_i, slice_j] += filtered_window[
: slice_i.stop - slice_i.start, : slice_j.stop - slice_j.start
]
return out

return apply_goldstein_filter(phase)
79 changes: 73 additions & 6 deletions src/dolphin/unwrap/_unwrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
from pathlib import Path
from typing import Optional, Sequence, Union

import numpy as np
from tqdm.auto import tqdm

from dolphin import io
from dolphin import goldstein, io
from dolphin._log import get_log, log_runtime
from dolphin._types import Filename
from dolphin.utils import DummyProcessPoolExecutor, full_suffix
Expand All @@ -18,7 +19,6 @@
DEFAULT_UNW_NODATA,
UNW_SUFFIX,
)
from ._snaphu_py import unwrap_snaphu_py
from ._tophu import multiscale_unwrap
from ._utils import create_combined_mask, set_nodata_values

Expand Down Expand Up @@ -47,6 +47,8 @@ def run(
ccl_nodata: int | None = DEFAULT_CCL_NODATA,
scratchdir: Optional[Filename] = None,
overwrite: bool = False,
run_goldstein: bool = False,
alpha: float = 0.5,
) -> tuple[list[Path], list[Path]]:
"""Run snaphu on all interferograms in a directory.

Expand Down Expand Up @@ -97,6 +99,10 @@ def run(
If None, uses `tophu`'s `/tmp/...` default.
overwrite : bool, optional, default = False
Overwrite existing unwrapped files.
run_goldstein : bool, optional, default = False
Whether to run Goldstein filtering on interferogram
alpha : float, optional, default = 0.5
Alpha parameter for Goldstein filtering

Returns
-------
Expand Down Expand Up @@ -159,6 +165,8 @@ def run(
unw_nodata=unw_nodata,
ccl_nodata=ccl_nodata,
scratchdir=scratchdir,
run_goldstein=run_goldstein,
alpha=alpha,
)
for ifg_file, out_file, cor_file in zip(in_files, out_files, cor_filenames)
]
Expand Down Expand Up @@ -190,6 +198,8 @@ def unwrap(
unw_nodata: float | None = DEFAULT_UNW_NODATA,
ccl_nodata: int | None = DEFAULT_CCL_NODATA,
scratchdir: Optional[Filename] = None,
run_goldstein: bool = False,
alpha: float = 0.5,
) -> tuple[Path, Path]:
"""Unwrap a single interferogram using snaphu, isce3, or tophu.

Expand Down Expand Up @@ -244,6 +254,10 @@ def unwrap(
scratchdir : Filename, optional
Path to scratch directory to hold intermediate files.
If None, uses `tophu`'s `/tmp/...` default.
run_goldstein : bool, optional, default = False
Whether to run Goldstein filtering on interferogram
alpha : float, optional, default = 0.5
Alpha parameter for Goldstein filtering

Returns
-------
Expand Down Expand Up @@ -272,12 +286,47 @@ def unwrap(
output_filename=combined_mask_file,
)

if run_goldstein:
suf = Path(unw_filename).suffix
if suf == ".tif":
driver = "GTiff"
opts = list(io.DEFAULT_TIFF_OPTIONS)
else:
driver = "ENVI"
opts = list(io.DEFAULT_ENVI_OPTIONS)
Comment on lines +291 to +296
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is baking in the assumption that we will only ever want to process GeoTiff files or ENVI files.

It'd be nice if dolphin had a function like

def get_raster_driver(filename: str | os.PathLike[str]) -> str:
    ...

that abstracted the logic for inferring the driver name of a particular raster, so that any future changes to this logic could be contained in a single function.

Similarly, it'd be nice to see a function like

def get_default_driver_options(driver: str) -> list[str]:
    ...

as the single source of truth for getting our default driver-specific raster creation options.

Copy link
Member

@scottstanie scottstanie Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also meant to comment here- get_raster_driver is a good one to add to the other similar io functions, but here I might actually move to cut the ENVI one and just produce geotiff outputs at this stage. I think Ryan got this ENVI check back when I was half way out of only setting up ENVI files, way before the wrapper/tophu was set up.

If we do want to accomodate more than GTiff, that would probably make more sense as a larger refactor to pass around DatasetWriters instead of Filenames. It would just require more thought how you would swap the user's DatasetWriter for a temporary one to hold the goldstein output


# If we're running Goldstein filtering, the intermediate
# filtered/unwrapped rasters are temporary rasters in the scratch dir.
filt_ifg_filename = (
Path(scratchdir or ".") / Path(ifg_filename).with_suffix(".filt" + suf).name
)
scratch_unw_filename = Path(unw_filename).with_suffix(".filt.unw" + suf)

ifg = io.load_gdal(ifg_filename)
logger.info(f"Goldstein filtering {ifg_filename} -> {filt_ifg_filename}")
filt_ifg = goldstein(ifg, alpha=alpha)
logger.info(f"Writing filtered output to {filt_ifg_filename}")
io.write_arr(
arr=filt_ifg,
output_name=filt_ifg_filename,
like_filename=ifg_filename,
driver=driver,
options=opts,
)
unwrapper_ifg_filename = filt_ifg_filename
unwrapper_unw_filename = scratch_unw_filename
else:
unwrapper_ifg_filename = Path(ifg_filename)
unwrapper_unw_filename = Path(unw_filename)
Comment on lines +316 to +320
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for @mirzaees and @taliboliver - this is where it could make sense to add the interpolation logic.
If this section gets unwieldy, perhaps we could break it into a helper function that returns the unwrapper ifg_filename and unw_filename


if unwrap_method == UnwrapMethod.SNAPHU:
from ._snaphu_py import unwrap_snaphu_py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was it causing any problems to have this imported at the top of the file? Or was this a mistake to move the import?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, yeah I moved this here because I was testing in an environment without snaphu. It was very convenient to not have to install snaphu simply by moving the import to where it's needed, but I can definitely move this back if that's an antipattern.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we can leave it for now if someone really want's to use another unwrapper that's not snaphu py, we're not currently putting snaphu-py in the environment file


# Pass everything to snaphu-py
unw_path, conncomp_path = unwrap_snaphu_py(
ifg_filename,
unwrapper_ifg_filename,
corr_filename,
unw_filename,
unwrapper_unw_filename,
nlooks,
ntiles=ntiles,
tile_overlap=tile_overlap,
Expand All @@ -292,9 +341,9 @@ def unwrap(
)
else:
unw_path, conncomp_path = multiscale_unwrap(
ifg_filename,
unwrapper_ifg_filename,
corr_filename,
unw_filename,
unwrapper_unw_filename,
downsample_factor,
ntiles=ntiles,
nlooks=nlooks,
Expand All @@ -321,4 +370,22 @@ def unwrap(
filename=conncomp_path, output_nodata=ccl_nodata, like_filename=ifg_filename
)

# Transfer ambiguity numbers from filtered unwrapped interferogram
# back to original interferogram
if run_goldstein:
logger.info(
f"Transferring ambiguity numbers from filtered ifg {scratch_unw_filename}"
)
unw_arr = io.load_gdal(scratch_unw_filename)

final_arr = np.angle(ifg) + (unw_arr - np.angle(filt_ifg))

io.write_arr(
arr=final_arr,
output_name=unw_filename,
dtype=np.float32,
driver=driver,
options=opts,
)

return unw_path, conncomp_path
13 changes: 13 additions & 0 deletions src/dolphin/workflows/config/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ class UnwrapOptions(BaseModel, extra="forbid"):
"Whether to run the unwrapping step after wrapped phase estimation."
),
)
run_goldstein: bool = Field(
False,
description=(
"Whether to run Goldstein filtering step on wrapped interferogram."
),
)
_directory: Path = PrivateAttr(Path("unwrapped"))
unwrap_method: UnwrapMethod = UnwrapMethod.SNAPHU
n_parallel_jobs: int = Field(
Expand Down Expand Up @@ -205,6 +211,13 @@ class UnwrapOptions(BaseModel, extra="forbid"):
description="Statistical cost mode method for SNAPHU.",
)

alpha: float = Field(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might end up changing this name slightly to avoid confusion with the SHP alpha parameter but for now it's fine and different

0.5,
description=(
"(for Goldstein filtering) Power parameter for Goldstein algorithm."
),
)

@field_validator("ntiles", "downsample_factor", mode="before")
@classmethod
def _to_tuple(cls, v):
Expand Down
2 changes: 2 additions & 0 deletions src/dolphin/workflows/unwrapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ def run(
downsample_factor=unwrap_options.downsample_factor,
unwrap_method=unwrap_options.unwrap_method,
scratchdir=unwrap_scratchdir,
run_goldstein=unwrap_options.run_goldstein,
alpha=unwrap_options.alpha,
)

if add_overviews:
Expand Down
15 changes: 15 additions & 0 deletions tests/test_unwrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ def test_unwrap_snaphu_nodata(self, tmp_path, list_of_gtiff_ifgs, corr_raster):
assert np.isnan(io.get_raster_nodata(unw_path))
assert io.get_raster_nodata(conncomp_path) == 123

@pytest.mark.parametrize("method", [UnwrapMethod.SNAPHU, UnwrapMethod.PHASS])
def test_goldstein(self, tmp_path, list_of_gtiff_ifgs, corr_raster, method):
# test other init_method
unw_filename = tmp_path / "unwrapped.unw.tif"
unw_path, conncomp_path = dolphin.unwrap.unwrap(
ifg_filename=list_of_gtiff_ifgs[0],
corr_filename=corr_raster,
unw_filename=unw_filename,
nlooks=1,
unwrap_method=method,
run_goldstein=True,
)
assert unw_path.exists()
assert conncomp_path.exists()


class TestUnwrapRun:
def test_run_gtiff(self, list_of_gtiff_ifgs, corr_raster):
Expand Down