Skip to content
Open
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
129 changes: 120 additions & 9 deletions NINA.WPF.Base/SkySurvey/CacheSkySurvey.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#region "copyright"

/*
Copyright � 2016 - 2026 Stefan Berg <isbeorn86+NINA@googlemail.com> and the N.I.N.A. contributors
Copyright � 2016 - 2026 Stefan Berg <isbeorn86+NINA@googlemail.com> and the N.I.N.A. contributors

This file is part of N.I.N.A. - Nighttime Imaging 'N' Astronomy.

Expand All @@ -15,6 +15,7 @@ This Source Code Form is subject to the terms of the Mozilla Public
using NINA.Astrometry;
using NINA.Core.Utility;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
Expand All @@ -27,12 +28,22 @@ This Source Code Form is subject to the terms of the Mozilla Public
namespace NINA.WPF.Base.SkySurvey {

public class CacheSkySurvey {
public const string Default = "Default";

public readonly string framingAssistantCachePath;
private string framingAssistantCachInfo;
private Dictionary<string, XElement> cachesBySource;
private Dictionary<string, string> cachePathsBySource;
private string activeSource = Default;
private string activeCachePath;

public string ActiveCachePath => string.IsNullOrEmpty(activeCachePath) ? framingAssistantCachePath : activeCachePath;

public CacheSkySurvey(string framingAssistantCachePath) {
this.framingAssistantCachePath = framingAssistantCachePath;
this.framingAssistantCachInfo = Path.Combine(framingAssistantCachePath, "CacheInfo.xml");
this.cachesBySource = new Dictionary<string, XElement>();
this.cachePathsBySource = new Dictionary<string, string>();
Initialize();
}

Expand Down Expand Up @@ -79,12 +90,18 @@ public void Clear() {


public void DeleteFromCache(XElement element) {
if(Cache != null && element != null) {
if(element != null) {
// Determine active cache document and paths
var doc = GetActiveCacheElement();
var basePath = ActiveCachePath;
var infoPath = (activeSource == Default || string.IsNullOrEmpty(activeSource))
? framingAssistantCachInfo
: Path.Combine(ActiveCachePath, "CacheInfo.xml");

var fileNameAttribute = element.Attribute("FileName");

if(fileNameAttribute != null) {
var fullFileName = Path.Combine(framingAssistantCachePath, fileNameAttribute.Value);
var fullFileName = Path.Combine(basePath, fileNameAttribute.Value);
var thumbnailBig = CacheImage.GetImagePathForThumbnail(fullFileName, CacheImage.BigThumbnailSize);
var thumbnailMedium = CacheImage.GetImagePathForThumbnail(fullFileName, CacheImage.MediumThumbnailSize);
var thumbnailSmall = CacheImage.GetImagePathForThumbnail(fullFileName, CacheImage.SmallThumbnailSize);
Expand All @@ -101,11 +118,10 @@ public void DeleteFromCache(XElement element) {
if (File.Exists(thumbnailSmall)) {
try { File.Delete(thumbnailSmall); } catch { }
}

}

element.Remove();
Cache.Save(framingAssistantCachInfo);
doc?.Save(infoPath);
}
}

Expand Down Expand Up @@ -219,6 +235,7 @@ private string RestoreNameFromUniqueBracket(string originalImgFilePath) {
/// <returns></returns>
public Task<SkySurveyImage> GetImage(string source, double ra, double dec, double rotation, double fov) {
return Task.Run(() => {
// Online sources and file cache are stored in the default cache (root)
var element =
Cache
.Elements("Image")
Expand All @@ -230,7 +247,7 @@ public Task<SkySurveyImage> GetImage(string source, double ra, double dec, doubl
.FirstOrDefault();

if (element != null) {
return Load(element);
return Load(element, framingAssistantCachePath);
}

return null;
Expand All @@ -246,15 +263,19 @@ public Task<SkySurveyImage> GetImage(Guid id) {
x => x.Attribute("Id").Value == id.ToString()
).FirstOrDefault();
if (element != null) {
return Load(element);
return Load(element, framingAssistantCachePath);
} else {
return null;
}
});
}

private SkySurveyImage Load(XElement element) {
var img = LoadJpg(element.Attribute("FileName").Value);
return Load(element, framingAssistantCachePath);
}

private SkySurveyImage Load(XElement element, string cachePath) {
var img = LoadJpg(element.Attribute("FileName").Value, cachePath);
Guid id = Guid.Parse(element.Attribute("Id").Value);
var fovW = double.Parse(element.Attribute("FoVW").Value, CultureInfo.InvariantCulture);
var fovH = double.Parse(element.Attribute("FoVH").Value, CultureInfo.InvariantCulture);
Expand All @@ -277,8 +298,12 @@ private SkySurveyImage Load(XElement element) {
}

private BitmapSource LoadJpg(string filename) {
return LoadJpg(filename, framingAssistantCachePath);
}

private BitmapSource LoadJpg(string filename, string cachePath) {
if (!Path.IsPathRooted(filename)) {
filename = Path.Combine(framingAssistantCachePath, filename);
filename = Path.Combine(cachePath, filename);
}

JpegBitmapDecoder JpgDec = new JpegBitmapDecoder(new Uri(filename), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
Expand All @@ -297,5 +322,91 @@ private BitmapSource ConvertTo96Dpi(BitmapSource image) {

return image;
}

/// <summary>
/// Scans subdirectories for cache sources and returns list of available sources
/// </summary>
public List<string> GetAvailableCacheSources() {
var sources = new List<string> { Default };

try {
if (Directory.Exists(framingAssistantCachePath)) {
var subdirs = Directory.GetDirectories(framingAssistantCachePath);
foreach (var subdir in subdirs) {
var dirName = Path.GetFileName(subdir);
var cacheInfoPath = Path.Combine(subdir, "CacheInfo.xml");

// Only add if it has a CacheInfo.xml file
if (File.Exists(cacheInfoPath)) {
sources.Add(dirName);
}
}
}
} catch (Exception ex) {
Logger.Error("Error scanning cache sources", ex);
}

return sources;
}

/// <summary>
/// Loads cache from a specific source (subdirectory or default)
/// </summary>
public void LoadCacheSource(string source) {
try {
string cachePath;
string cacheInfoPath;

if (source == Default || string.IsNullOrEmpty(source)) {
cachePath = framingAssistantCachePath;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The default entry is a bit odd. I would assume that you either have only one survey folder or one that is setup only with subfolders and not a mix.
To a user the default entry in the combobox might also be confusing - It makes sense internally as the backwards compatible fallback, but i would expect only my subfolders to be available when the cache is set up with multiple caches.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@isbeorn thanks for comment!

Default name can be probably odd, may be Root or anything other can fit.
My intention to have main directory with subdirectores are
a) backward compatibility. cache is also used in Sky Atlas screen. In case the user has "subdirs only solution" we have to provide a setting to choose main cache to be used in Sky Atlas
b) easier transition without migrations. I suppose many users already have FramingAssistantCache setup, and then we tell them "move you cache to a subdir and add other subdirs" but then what "sky survey setting folder" should point to? Can be misunderstanding there, IMO. Or we should provide migration script?
c) In case a user doesn't plan to add subfolders, do we force to move current default cache to subfolder?
d) I believe a fallback is required in case of emergency

May be a Tooltip can be added explaining what is Root or Default (with path or not). What do you think?
Also, currently I'm hiding combobox if subdirs are not detected. So that behavior is totally the same for all user. May be for transparency we should show it in all cases?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

What i don't want is a mix of root level and sub folders for the cache. A migration should be easy to handle in case there is such a mix done.

I see these scenarios:

  • No subfolders: Old behavior - no combobox shown
  • Subfolders available:
    • Root level has cache and sub folders have cache
      • Move the root level into a new folder "Default"
    • Root level has no cache, but at least one sub folders has a cache
      • Show the subfolder names in the combobox

Copy link
Copy Markdown
Author

@naixx naixx Oct 30, 2025

Choose a reason for hiding this comment

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

What i don't want is a mix of root level and sub folders for the cache.

Any specific reasons for this?

What about Sky Atlas?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi, as the creator of the additional sky caches based on the Northern Sky Narrowband Survey I had some thoughts on a possible integration as well.

Would it make sense under options to simply add 1-2 additional framing cache paths which in the framing assistant can be selected from the existing drop down like the regular sky cache?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

What i don't want is a mix of root level and sub folders for the cache.

Any specific reasons for this?

What about Sky Atlas?

it's just a confusing setup for a user

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@astroalex80 thanks for surveys!

As a user I wanted to have all of the surveys, I especially liked Ha and rgb versions. So having 8-9 paths in settings just to provide different surveys seed to me like an overkill. That's why I created this solution - just drop subdirs and forget. And you can manage which preview is shown is Sky Atlas by changing root dir, so that I can have narrowband previews in search.

Of course it can be done in more manageable way with migrations, metadata etc, but for me it's a matter of effort and winnings. Which I don't clearly see

Copy link
Copy Markdown
Contributor

@astroalex80 astroalex80 Oct 30, 2025

Choose a reason for hiding this comment

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

Agree, keep it simple also as the creation of additional sky survey caches is quite unlikely due to missing sky survey sources with a reasonably large coverage and (visual) improvement over the existing cache. There are tons of surveys of course but not better than the DSS when it comes to visual spectrum. For narrowband there are even less sources.

For H-Alpha I am working on the MDW survey to make it available as an additional cache as it is of excellent quality and allsky. Of course, licence questions must be answered and permission be granted but giving the fact they are public available hosted by the Columbia University this shouldn't be a show stopper.

@daleghent the new (Northern Sky Narrowband Survey) caches include a licence txt file with clear description of their origin/source.

cacheInfoPath = framingAssistantCachInfo;
} else {
cachePath = Path.Combine(framingAssistantCachePath, source);
cacheInfoPath = Path.Combine(cachePath, "CacheInfo.xml");
}

if (!Directory.Exists(cachePath)) {
Directory.CreateDirectory(cachePath);
}

XElement cacheElement;
if (!File.Exists(cacheInfoPath)) {
cacheElement = new XElement("ImageCacheInfo");
cacheElement.Save(cacheInfoPath);
} else {
cacheElement = XElement.Load(cacheInfoPath);

// Ensure Backwards compatibility
var elements = cacheElement.Elements("Image").Where(x => x.Attribute("Id") == null);
foreach (var element in elements) {
element.Add(new XAttribute("Id", Guid.NewGuid()));
}
elements = cacheElement.Elements("Image").Where(x => x.Attribute("Source") == null);
foreach (var element in elements) {
if (element.Attribute("Rotation").Value != "0") {
element.Add(new XAttribute("Source", nameof(FileSkySurvey)));
} else {
element.Add(new XAttribute("Source", nameof(NASASkySurvey)));
}
}
}

cachesBySource[source] = cacheElement;
cachePathsBySource[source] = cachePath;

// set active source context
activeSource = string.IsNullOrEmpty(source) ? Default : source;
activeCachePath = cachePath;
} catch (Exception ex) {
Logger.Error($"Error loading cache source {source}", ex);
}
}

public XElement GetActiveCacheElement() {
if (activeSource == Default || !cachesBySource.ContainsKey(activeSource)) {
return Cache;
}
return cachesBySource[activeSource];
}
}
}
7 changes: 4 additions & 3 deletions NINA.WPF.Base/SkySurvey/SkyMapAnnotator.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#region "copyright"

/*
Copyright � 2016 - 2026 Stefan Berg <isbeorn86+NINA@googlemail.com> and the N.I.N.A. contributors
Copyright � 2016 - 2026 Stefan Berg <isbeorn86+NINA@googlemail.com> and the N.I.N.A. contributors

This file is part of N.I.N.A. - Nighttime Imaging 'N' Astronomy.

Expand Down Expand Up @@ -300,7 +300,8 @@ private List<CacheImage> GetCacheImagesForViewport() {
double maxSize = 600;

var l = new List<CacheImage>();
foreach (var entry in cache.Cache.Elements("Image")) {
var cacheDoc = cache?.GetActiveCacheElement();
foreach (var entry in cacheDoc?.Elements("Image") ?? Enumerable.Empty<System.Xml.Linq.XElement>()) {
double fovW = double.Parse(entry.Attribute("FoVW").Value, CultureInfo.InvariantCulture);
double fovH = double.Parse(entry.Attribute("FoVH").Value, CultureInfo.InvariantCulture);

Expand All @@ -311,7 +312,7 @@ private List<CacheImage> GetCacheImagesForViewport() {
double ra = double.Parse(entry.Attribute("RA").Value, CultureInfo.InvariantCulture);
double dec = double.Parse(entry.Attribute("Dec").Value, CultureInfo.InvariantCulture);
double rotation = double.Parse(entry.Attribute("Rotation").Value, CultureInfo.InvariantCulture);
string path = Path.Combine(cache.framingAssistantCachePath, entry.Attribute("FileName").Value);
string path = Path.Combine(cache.ActiveCachePath, entry.Attribute("FileName").Value);

if (AstroUtil.ArcminToArcsec(fovW) > minSize && AstroUtil.ArcminToArcsec(fovH) > minSize) {
var existing = cacheImages.FirstOrDefault(x => x.Coordinates.RA == ra && x.Coordinates.Dec == dec);
Expand Down
29 changes: 29 additions & 0 deletions NINA/View/FramingAssistantView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,35 @@
</StackPanel>
</Grid>

<Grid>
<!-- SKYATLAS (Offline Sky Map) Specific UI -->
<Grid.Style>
<Style TargetType="{x:Type Grid}">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding ElementName=PART_FramingAssistantSource, Path=SelectedItem}" Value="{x:Static enum:SkySurveySource.SKYATLAS}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>

<Grid Margin="0,5,0,0"
VerticalAlignment="Center"
Visibility="{Binding AvailableCacheSources, Converter={StaticResource CollectionContainsItemsToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="FramingLabel"/>
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" Text="Cache source" />
<ComboBox
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

the combobox should have the same alignment as the image source

Grid.Column="1"
Margin="10,0,0,0"
ItemsSource="{Binding AvailableCacheSources}"
SelectedItem="{Binding SelectedCacheSource}" />
</Grid>
</Grid>

<Grid>
<!-- Cache Specific UI -->
<Grid.Style>
Expand Down
50 changes: 48 additions & 2 deletions NINA/ViewModel/FramingAssistant/FramingAssistantVM.cs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,15 @@ private void InitializeCache() {
Cache = new CacheSkySurvey(profileService.ActiveProfile.ApplicationSettings.SkySurveyCacheDirectory);
ImageCacheInfo = Cache.Cache;
_selectedImageCacheInfo = (XElement)ImageCacheInfo?.FirstNode ?? null;

// Load available cache sources
var sources = Cache.GetAvailableCacheSources();
if (!sources.SequenceEqual(new[] { CacheSkySurvey.Default }))
AvailableCacheSources = sources;
else
AvailableCacheSources = null;
SelectedCacheSource = CacheSkySurvey.Default;

RaisePropertyChanged(nameof(ImageCacheInfo));
} catch (Exception ex) {
Logger.Error(ex);
Expand Down Expand Up @@ -624,8 +633,7 @@ private void ClearCache(object obj) {
SkyMapAnnotator.ClearImagesForViewport();

Cache.Clear();
ImageCacheInfo = Cache.Cache;
RaisePropertyChanged(nameof(ImageCacheInfo));
InitializeCache();
}
}
}
Expand Down Expand Up @@ -902,6 +910,39 @@ public SkySurveySource FramingAssistantSource {
}
}

private List<string> _availableCacheSources;

public List<string> AvailableCacheSources {
get => _availableCacheSources;
set {
_availableCacheSources = value;
RaisePropertyChanged();
}
}

private string _selectedCacheSource = CacheSkySurvey.Default;

public string SelectedCacheSource {
get => _selectedCacheSource;
set {
if (_selectedCacheSource != value) {
_selectedCacheSource = value;
if (Cache != null) {
Cache.LoadCacheSource(value);
ImageCacheInfo = Cache.GetActiveCacheElement();
RaisePropertyChanged(nameof(ImageCacheInfo));
try {
SkyMapAnnotator?.ClearImagesForViewport();
SkyMapAnnotator?.UpdateSkyMap();
} catch { }

LoadImage();
}
RaisePropertyChanged();
}
}
}

private double _cameraPixelSize;

public double CameraPixelSize {
Expand Down Expand Up @@ -1104,6 +1145,11 @@ private async Task<bool> LoadImage() {
SkyMapAnnotator.UseCachedImages = false;
}

// Load cache from selected source if SKYATLAS is selected
if (FramingAssistantSource == SkySurveySource.SKYATLAS && Cache != null) {
Cache.LoadCacheSource(SelectedCacheSource);
}

if (Cache != null && DSO != null) {
try {
skySurveyImage = await Cache.GetImage(FramingAssistantSource.GetCacheSourceString(), DSO.Coordinates.RA, DSO.Coordinates.Dec, 360 - DSO.RotationPositionAngle, AstroUtil.DegreeToArcmin(FieldOfView));
Expand Down