ScanSlicer/scanslicer/params.py

243 lines
8.7 KiB
Python
Raw Normal View History

import json
import os
class Parameter:
def __init__(self, default, min, max, step, label, label_secondary=""):
self.default = default
self.min = min
self.max = max
self.step = step
self.label = label
self.label_secondary = label_secondary
self.value = default
def get(self):
return self.value
def set(self, value):
int_value = int(value)
if int_value < self.min:
int_value = self.min
if int_value > self.max:
int_value = self.max
self.value = int_value
def reset(self):
self.value = self.default
class AutoslicerParams:
SPECS = [
("detect_pipeline", 0, 0, 1, 1, "Edge Detector", "(0=Threshold, 1=Canny)"),
("clahe_clip", 0, 0, 40, 1, "Auto Contrast", "(0=disabled)"),
("gaussian", 0, 0, 100, 1, "Noise Reduction", "(0=disabled)"),
("bw_method", 0, 0, 2, 1, "Detection Mode", "(0=Simple, 1=Gauss, 2=Otsu)"),
("bw_thresh", 210, 0, 255, 5, "Brightness Cutoff"),
("bw_gauss", 64, 0, 1000, 2, "Contrast Window"),
("bw_adaptive_c", 2, -20, 20, 1, "Adaptive C (Gauss)"),
("canny_low", 50, 0, 255, 5, "Canny Low Threshold"),
("canny_high", 150, 0, 255, 5, "Canny High Threshold"),
("invert_mode", 0, 0, 2, 1, "Invert Detection", "(0=Auto, 1=Off, 2=On)"),
("morph_op", 1, 0, 4, 1, "Morph Operation", "(0=None, 1=Dilate, 2=Erode, 3=Open, 4=Close)"),
("contour_approx_pct", 0, 0, 50, 1, "Contour Smoothing", "(epsilon %, x10)"),
("bbox_min_size", 2, 0, 100, 1, "Min Photo Size ", "(% total)"),
("bbox_max_size", 90, 1, 100, 1, "Max Photo Size ", "(% total)"),
("bbox_fill_thresh", 10, 0, 100, 1, "Fill Ratio"),
("expected_count", 0, 0, 20, 1, "Expected Photos", "(0=auto)"),
("aspect_ratio_min", 100, 100, 1000, 5, "Min Aspect Ratio", "(x100, long/short)"),
("aspect_ratio_max", 400, 100, 1000, 5, "Max Aspect Ratio", "(x100, long/short)"),
("border_ignore", 0, 0, 20, 1, "Ignore Border Margin", "(% of width/height)"),
("iou_merge_thresh", 30, 0, 100, 1, "Merge Overlap Threshold", "(IoU %)"),
("dilate_kernel", 30, 0, 500, 1, "Edge Expansion", "(0=disabled)"),
]
PRESETS = {}
def __init__(self):
for spec in self.SPECS:
if len(spec) == 6:
name, default, min_value, max_value, step, label = spec
label_secondary = ""
elif len(spec) == 7:
name, default, min_value, max_value, step, label, label_secondary = spec
else:
raise ValueError("Invalid parameter spec: expected 6 or 7 values")
setattr(
self,
name,
Parameter(default, min_value, max_value, step, label, label_secondary),
)
self.user_presets = self.load_user_presets()
self.load_user_defaults()
def items(self):
for name, *_ in self.SPECS:
yield name, getattr(self, name)
@classmethod
def defaults_path(cls):
override = os.environ.get("SCANSLICER_DEFAULTS_PATH")
if override:
return override
if os.path.exists("/.dockerenv") and os.path.isdir("/work"):
return os.path.join("/work", ".scanslicer", "defaults.json")
config_home = os.environ.get("XDG_CONFIG_HOME")
if not config_home:
config_home = os.path.join(os.path.expanduser("~"), ".config")
return os.path.join(config_home, "scanslicer", "defaults.json")
@classmethod
def presets_path(cls):
override = os.environ.get("SCANSLICER_PRESETS_PATH")
if override:
return override
if os.path.exists("/.dockerenv") and os.path.isdir("/work"):
return os.path.join("/work", ".scanslicer", "presets.json")
config_home = os.environ.get("XDG_CONFIG_HOME")
if not config_home:
config_home = os.path.join(os.path.expanduser("~"), ".config")
return os.path.join(config_home, "scanslicer", "presets.json")
def to_dict(self):
return {name: parameter.get() for name, parameter in self.items()}
def apply_values(self, values):
if not isinstance(values, dict):
return
values = dict(values)
legacy_map = {
"bbox_min_size_prop": "bbox_min_size",
"bbox_max_size_prop": "bbox_max_size",
"morph_kernel": "dilate_kernel",
}
for legacy_key, new_key in legacy_map.items():
if new_key not in values and legacy_key in values:
values[new_key] = values[legacy_key]
for name, parameter in self.items():
if name in values:
parameter.set(values[name])
if self.aspect_ratio_min.get() > self.aspect_ratio_max.get():
self.aspect_ratio_max.set(self.aspect_ratio_min.get())
if self.canny_low.get() > self.canny_high.get():
self.canny_high.set(self.canny_low.get())
def reset_to_factory_defaults(self):
for _, parameter in self.items():
parameter.reset()
def load_user_defaults(self):
path = self.defaults_path()
if not os.path.isfile(path):
return False
try:
with open(path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return False
if not isinstance(loaded, dict):
return False
self.apply_values(loaded)
return True
def load_defaults(self):
if self.load_user_defaults():
return "saved"
self.reset_to_factory_defaults()
return "factory"
def save_user_defaults(self):
path = self.defaults_path()
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(self.to_dict(), fh, indent=2, sort_keys=True)
return path
def load_user_presets(self):
path = self.presets_path()
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return {}
if not isinstance(loaded, dict):
return {}
cleaned = {}
for name, values in loaded.items():
if not isinstance(name, str):
continue
normalized_name = name.strip()
if not normalized_name:
continue
if not isinstance(values, dict):
continue
cleaned[normalized_name] = values
return cleaned
def save_user_presets(self):
path = self.presets_path()
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(self.user_presets, fh, indent=2, sort_keys=True)
return path
def preset_names(self):
builtin_names = list(self.PRESETS.keys())
user_names = sorted(self.user_presets.keys(), key=str.lower)
return builtin_names + user_names
def save_named_preset(self, preset_name):
normalized_name = str(preset_name).strip()
if len(normalized_name) == 0:
raise ValueError("Preset name cannot be empty.")
if normalized_name.lower() == "custom":
raise ValueError("Preset name 'Custom' is reserved.")
if normalized_name in self.PRESETS:
raise ValueError(f"'{normalized_name}' is a built-in preset name.")
self.user_presets[normalized_name] = self.to_dict()
self.save_user_presets()
return normalized_name
def delete_named_preset(self, preset_name):
normalized_name = str(preset_name).strip()
if len(normalized_name) == 0:
raise ValueError("Preset name cannot be empty.")
if normalized_name.lower() == "custom":
raise ValueError("Preset name 'Custom' cannot be deleted.")
if normalized_name in self.PRESETS:
raise ValueError(f"'{normalized_name}' is a built-in preset and cannot be deleted.")
if normalized_name not in self.user_presets:
raise ValueError(f"Preset '{normalized_name}' was not found.")
del self.user_presets[normalized_name]
self.save_user_presets()
return normalized_name
def apply_preset(self, preset_name):
values = self.PRESETS.get(preset_name)
if values is None:
values = self.user_presets.get(preset_name)
if values is None:
return False
self.apply_values(values)
return True