Merge pull request #71 from outis1one/claude/moon-photography-enhancement-dD9R6
Add waxing/waning crescent phases; fix phase-shadow double-application
This commit is contained in:
+5
-6
@@ -254,12 +254,11 @@ def composite_full_moon(
|
||||
ImageFilter.UnsharpMask(radius=2, percent=160, threshold=2)
|
||||
)
|
||||
|
||||
# ── Phase shadow (skip when essentially full) ──
|
||||
illum = moon_phase.illumination(when_utc)
|
||||
pa = moon_phase.phase_angle(when_utc)
|
||||
if illum < 0.995:
|
||||
wax = moon_phase.waxing(when_utc)
|
||||
ref_resized = _apply_phase_shadow(ref_resized, pa, wax)
|
||||
# Phase shadow is NOT applied here: the NASA SVS Dial-a-Moon source already
|
||||
# renders the correct terminator, libration and earthshine for the exact
|
||||
# hour. Applying _apply_phase_shadow on top would double-darken the unlit
|
||||
# limb. (_apply_phase_shadow remains available for callers that supply a
|
||||
# full-moon reference photo rather than a phase-correct NASA render.)
|
||||
|
||||
# ── Parallactic-angle rotation ──
|
||||
par = moon_phase.parallactic_angle(when_utc)
|
||||
|
||||
+21
-9
@@ -78,12 +78,14 @@ def _grayscale_array(image_path: str) -> np.ndarray:
|
||||
return np.asarray(im)
|
||||
|
||||
|
||||
def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray] | None:
|
||||
def _largest_round_blob(
|
||||
mask: np.ndarray,
|
||||
min_roundness: float = MIN_ROUNDNESS,
|
||||
) -> tuple[int, np.ndarray, np.ndarray] | None:
|
||||
labels, n = ndimage.label(mask)
|
||||
if n == 0:
|
||||
return None
|
||||
sizes = ndimage.sum(mask, labels, range(1, n + 1))
|
||||
# Sort blobs by size descending; check roundness on the top few
|
||||
order = np.argsort(sizes)[::-1]
|
||||
for idx in order[:8]:
|
||||
label_id = idx + 1
|
||||
@@ -97,23 +99,33 @@ def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray]
|
||||
continue
|
||||
radius = diam / 2.0
|
||||
roundness = len(xs) / (math.pi * radius * radius)
|
||||
if roundness < MIN_ROUNDNESS:
|
||||
if roundness < min_roundness:
|
||||
continue
|
||||
return label_id, ys, xs
|
||||
return None
|
||||
|
||||
|
||||
def detect_moon(image_path: str) -> MoonDetection | None:
|
||||
"""Return MoonDetection or None if no acceptable moon is found."""
|
||||
def detect_moon(
|
||||
image_path: str,
|
||||
saturated_threshold: int = SATURATED_THRESHOLD,
|
||||
min_roundness: float = MIN_ROUNDNESS,
|
||||
max_halo_ratio: float = MAX_HALO_RATIO,
|
||||
) -> MoonDetection | None:
|
||||
"""Return MoonDetection or None if no acceptable moon is found.
|
||||
|
||||
saturated_threshold / min_roundness / max_halo_ratio can be relaxed for
|
||||
crescent phases, which produce a dim, non-circular arc rather than a bright
|
||||
near-perfect disk.
|
||||
"""
|
||||
a = _grayscale_array(image_path)
|
||||
if OVERLAY_PX > 0:
|
||||
a = a.copy()
|
||||
a[-OVERLAY_PX:, :] = 0
|
||||
mask = a >= SATURATED_THRESHOLD
|
||||
mask = a >= saturated_threshold
|
||||
if not mask.any():
|
||||
return None
|
||||
|
||||
found = _largest_round_blob(mask)
|
||||
found = _largest_round_blob(mask, min_roundness)
|
||||
if found is None:
|
||||
return None
|
||||
_, ys, xs = found
|
||||
@@ -153,12 +165,12 @@ def detect_moon(image_path: str) -> MoonDetection | None:
|
||||
yh, xh = np.where(halo_labels == halo_id)
|
||||
halo_radius = max(xh.max() - xh.min(), yh.max() - yh.min()) / 2.0
|
||||
halo_ratio = halo_radius / radius if radius > 0 else 1.0
|
||||
if halo_ratio > MAX_HALO_RATIO:
|
||||
if halo_ratio > max_halo_ratio:
|
||||
return None # too much glow → probably thick cloud cover
|
||||
|
||||
# Quality score: roundness (0..1), low halo (1 = clear, 0 = thick cloud),
|
||||
# isolation factor (1 if very isolated, less if close to other lights).
|
||||
halo_clean = max(0.0, min(1.0, (MAX_HALO_RATIO - halo_ratio) / (MAX_HALO_RATIO - 1.5)))
|
||||
halo_clean = max(0.0, min(1.0, (max_halo_ratio - halo_ratio) / (max_halo_ratio - 1.5)))
|
||||
iso_factor = 1.0 if isolation == float('inf') else min(1.0, isolation / 600.0)
|
||||
quality = 0.5 * roundness + 0.35 * halo_clean + 0.15 * iso_factor
|
||||
|
||||
|
||||
@@ -107,6 +107,46 @@ def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]:
|
||||
return phase_events_in_range(start, end, 2)
|
||||
|
||||
|
||||
def crescent_times_in_range(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
target_illum: float = 0.28,
|
||||
waxing_side: bool = True,
|
||||
) -> list[datetime]:
|
||||
"""Return UTC datetimes when illumination crosses target_illum on the given side.
|
||||
|
||||
Scans in 2-hour steps and records each moment illumination passes through
|
||||
target_illum while waxing (waxing_side=True) or waning (waxing_side=False).
|
||||
Yields one event per lunar cycle, used for scheduling just like
|
||||
phase_events_in_range() is used for quarter/full events.
|
||||
"""
|
||||
_lazy()
|
||||
step = timedelta(hours=2)
|
||||
results: list[datetime] = []
|
||||
t = _to_utc(start)
|
||||
end_dt = _to_utc(end)
|
||||
prev_illum: float | None = None
|
||||
prev_t: datetime | None = None
|
||||
|
||||
while t <= end_dt:
|
||||
illum_val = illumination(t)
|
||||
is_wax = waxing(t)
|
||||
if is_wax == waxing_side:
|
||||
if prev_illum is not None:
|
||||
if (prev_illum - target_illum) * (illum_val - target_illum) < 0:
|
||||
# Linear interpolation to the crossing moment
|
||||
frac = (target_illum - prev_illum) / (illum_val - prev_illum)
|
||||
results.append(prev_t + frac * (t - prev_t))
|
||||
prev_illum = illum_val
|
||||
prev_t = t
|
||||
else:
|
||||
prev_illum = None
|
||||
prev_t = None
|
||||
t += step
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def phase_events_in_range(start: datetime, end: datetime, phase_index: int) -> list[datetime]:
|
||||
"""Return UTC datetimes of every occurrence of `phase_index` between start and end.
|
||||
|
||||
|
||||
+65
-9
@@ -87,6 +87,14 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f
|
||||
|
||||
DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30))
|
||||
|
||||
# Crescent detection uses relaxed thresholds: a crescent arc scores low on
|
||||
# roundness and is dimmer than a full disk.
|
||||
CRESCENT_TARGET_ILLUM = float(CONF.get('MOON_CRESCENT_TARGET_ILLUM', 0.28))
|
||||
CRESCENT_MIN_QUALITY = float(CONF.get('MOON_CRESCENT_MIN_QUALITY', 0.35))
|
||||
CRESCENT_SATURATED_THR = int(CONF.get('MOON_CRESCENT_SATURATED_THR', 180))
|
||||
CRESCENT_MIN_ROUNDNESS = float(CONF.get('MOON_CRESCENT_MIN_ROUNDNESS', 0.15))
|
||||
CRESCENT_MAX_HALO_RATIO = float(CONF.get('MOON_CRESCENT_MAX_HALO_RATIO', 12.0))
|
||||
|
||||
|
||||
PHASE_SPEC = {
|
||||
'full': {
|
||||
@@ -110,6 +118,28 @@ PHASE_SPEC = {
|
||||
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
|
||||
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
|
||||
},
|
||||
# Crescents have no fixed skyfield phase index — timing is computed via
|
||||
# crescent_times_in_range() at the configured illumination target (~28%).
|
||||
# Detection uses relaxed thresholds since a crescent arc is non-circular
|
||||
# and dimmer than a full disk. The atmospheric background naturally picks
|
||||
# up twilight colours from the real east frame (dawn for waning, dusk/dawn
|
||||
# depending on the month for waxing).
|
||||
'waxing-crescent': {
|
||||
'crescent': True,
|
||||
'waxing': True,
|
||||
'label': 'Waxing Crescent',
|
||||
'emoji': '🌒',
|
||||
'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)),
|
||||
'enabled_key': 'MOON_WAXING_CRESCENT_ENABLED',
|
||||
},
|
||||
'waning-crescent': {
|
||||
'crescent': True,
|
||||
'waxing': False,
|
||||
'label': 'Waning Crescent',
|
||||
'emoji': '🌘',
|
||||
'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)),
|
||||
'enabled_key': 'MOON_WANING_CRESCENT_ENABLED',
|
||||
},
|
||||
}
|
||||
|
||||
_FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$')
|
||||
@@ -281,12 +311,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
import moon_phase
|
||||
from moon_detect import detect_moon
|
||||
|
||||
is_crescent = spec.get('crescent', False)
|
||||
|
||||
if target_utc is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
events = moon_phase.phase_events_in_range(
|
||||
now - timedelta(days=45), now, spec['index'])
|
||||
if is_crescent:
|
||||
events = moon_phase.crescent_times_in_range(
|
||||
now - timedelta(days=35), now,
|
||||
target_illum=CRESCENT_TARGET_ILLUM,
|
||||
waxing_side=spec['waxing'],
|
||||
)
|
||||
else:
|
||||
events = moon_phase.phase_events_in_range(
|
||||
now - timedelta(days=45), now, spec['index'])
|
||||
if not events:
|
||||
print(f'no recent {phase} found in past 45 days', file=sys.stderr)
|
||||
print(f'no recent {phase} found', file=sys.stderr)
|
||||
return 1
|
||||
target_utc = events[-1]
|
||||
|
||||
@@ -336,12 +375,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
# is the one temporally closest to the moon being precisely full/quarter.
|
||||
dark_frames.sort(key=lambda x: abs(x[1] - target_utc))
|
||||
|
||||
# Crescent arcs are non-circular and dimmer — use relaxed detection params.
|
||||
det_kwargs = (
|
||||
dict(saturated_threshold=CRESCENT_SATURATED_THR,
|
||||
min_roundness=CRESCENT_MIN_ROUNDNESS,
|
||||
max_halo_ratio=CRESCENT_MAX_HALO_RATIO)
|
||||
if is_crescent else {}
|
||||
)
|
||||
min_q = CRESCENT_MIN_QUALITY if is_crescent else MIN_QUALITY
|
||||
|
||||
best_frame: str | None = None
|
||||
best_det = None
|
||||
best_dt: datetime | None = None
|
||||
for fpath, fdt in dark_frames:
|
||||
det = detect_moon(fpath)
|
||||
if det is not None and det.quality >= MIN_QUALITY:
|
||||
det = detect_moon(fpath, **det_kwargs)
|
||||
if det is not None and det.quality >= min_q:
|
||||
best_frame = fpath
|
||||
best_det = det
|
||||
best_dt = fdt
|
||||
@@ -451,16 +499,24 @@ def auto_run(cam: str, dry_run: bool, no_upload: bool) -> int:
|
||||
"""Daily check: run any phase whose post-day equals today (UTC)."""
|
||||
import moon_phase
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
window_start = datetime.combine(
|
||||
today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
ran_any = False
|
||||
rc = 0
|
||||
for phase, spec in PHASE_SPEC.items():
|
||||
if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
|
||||
print(f'-- {phase}: {spec["enabled_key"]}=false → skip')
|
||||
continue
|
||||
events = moon_phase.phase_events_in_range(
|
||||
datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc),
|
||||
datetime.now(timezone.utc),
|
||||
spec['index'])
|
||||
if spec.get('crescent'):
|
||||
events = moon_phase.crescent_times_in_range(
|
||||
window_start, now,
|
||||
target_illum=CRESCENT_TARGET_ILLUM,
|
||||
waxing_side=spec['waxing'],
|
||||
)
|
||||
else:
|
||||
events = moon_phase.phase_events_in_range(
|
||||
window_start, now, spec['index'])
|
||||
if not events:
|
||||
continue
|
||||
last_event = events[-1]
|
||||
|
||||
Reference in New Issue
Block a user