diff --git "a/manga_settings_dialog.py" "b/manga_settings_dialog.py" new file mode 100644--- /dev/null +++ "b/manga_settings_dialog.py" @@ -0,0 +1,4417 @@ +# manga_settings_dialog.py +""" +Enhanced settings dialog for manga translation with all settings visible +Properly integrated with TranslatorGUI's WindowManager and UIHelper +""" + +import os +import json +from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QPushButton, QCheckBox, QSpinBox, QDoubleSpinBox, + QSlider, QComboBox, QLineEdit, QGroupBox, QTabWidget, + QWidget, QScrollArea, QFrame, QRadioButton, QButtonGroup, + QMessageBox, QFileDialog, QSizePolicy, QApplication) +from PySide6.QtCore import Qt, Signal, QTimer, QEvent, QObject +from PySide6.QtGui import QFont, QIcon +from typing import Dict, Any, Optional, Callable +from bubble_detector import BubbleDetector +import logging +import time +import copy + +# Use the same logging infrastructure initialized by translator_gui +logger = logging.getLogger(__name__) + +class MangaSettingsDialog(QDialog): + """Settings dialog for manga translation""" + + def __init__(self, parent, main_gui, config: Dict[str, Any], callback: Optional[Callable] = None): + """Initialize settings dialog + + Args: + parent: Parent window (should be QWidget or None) + main_gui: Reference to TranslatorGUI instance + config: Configuration dictionary + callback: Function to call after saving + """ + # Ensure parent is a QWidget or None for proper PySide6 initialization + if parent is not None and not hasattr(parent, 'windowTitle'): + # If parent is not a QWidget, use None + parent = None + super().__init__(parent) + self.parent = parent + self.main_gui = main_gui + self.config = config + self.callback = callback + + # Make dialog non-modal so it doesn't block the manga integration GUI + self.setModal(False) + + # Enhanced default settings structure with all options + self.default_settings = { + 'preprocessing': { + 'enabled': False, + 'auto_detect_quality': True, + 'contrast_threshold': 0.4, + 'sharpness_threshold': 0.3, + 'noise_threshold': 20, + 'enhancement_strength': 1.5, + 'denoise_strength': 10, + 'max_image_dimension': 2000, + 'max_image_pixels': 2000000, + 'chunk_height': 2000, + 'chunk_overlap': 100, + # Inpainting tiling + 'inpaint_tiling_enabled': False, # Off by default + 'inpaint_tile_size': 512, # Default tile size + 'inpaint_tile_overlap': 64 # Overlap to avoid seams + }, + 'compression': { + 'enabled': False, + 'format': 'jpeg', + 'jpeg_quality': 85, + 'png_compress_level': 6, + 'webp_quality': 85 + }, + 'ocr': { + 'language_hints': ['ja', 'ko', 'zh'], + 'confidence_threshold': 0.7, + 'min_region_size': 50, # Minimum dimension for cloud OCR regions (0 = disabled) + 'merge_nearby_threshold': 20, + 'azure_merge_multiplier': 3.0, + 'text_detection_mode': 'document', + 'enable_rotation_correction': True, + 'bubble_detection_enabled': True, + 'roi_locality_enabled': False, + 'bubble_model_path': '', + 'bubble_confidence': 0.3, + 'detector_type': 'rtdetr_onnx', + 'rtdetr_confidence': 0.3, + 'detect_empty_bubbles': True, + 'detect_text_bubbles': True, + 'detect_free_text': True, + 'rtdetr_model_url': '', + 'use_rtdetr_for_ocr_regions': True, # On by default for best accuracy + # Azure settings removed - new API is synchronous, no polling/version settings needed + 'min_text_length': 0, + 'exclude_english_text': False, + 'english_exclude_threshold': 0.7, + 'english_exclude_min_chars': 4, + 'english_exclude_short_tokens': False + }, + 'advanced': { + 'format_detection': True, + 'webtoon_mode': 'auto', + 'debug_mode': False, + 'save_intermediate': False, + 'parallel_processing': True, + 'max_workers': 2, + 'parallel_panel_translation': False, + 'panel_max_workers': 2, + 'use_singleton_models': False, + 'auto_cleanup_models': False, + 'unload_models_after_translation': False, + 'auto_convert_to_onnx': False, # Disabled by default + 'auto_convert_to_onnx_background': True, + 'quantize_models': False, + 'onnx_quantize': False, + 'torch_precision': 'fp16', + # HD strategy defaults (mirrors comic-translate) + 'hd_strategy': 'resize', # 'original' | 'resize' | 'crop' + 'hd_strategy_resize_limit': 1536, # long-edge cap for resize + 'hd_strategy_crop_margin': 16, # pixels padding around cropped ROIs + 'hd_strategy_crop_trigger_size': 1024, # only crop if long edge exceeds this + # RAM cap defaults + 'ram_cap_enabled': False, + 'ram_cap_mb': 4096, + 'ram_cap_mode': 'soft', + 'ram_gate_timeout_sec': 15.0, + 'ram_min_floor_over_baseline_mb': 256 + }, + 'inpainting': { + 'batch_size': 10, + 'enable_cache': True, + 'method': 'local', + 'local_method': 'anime' + }, + 'font_sizing': { + 'algorithm': 'smart', # 'smart', 'conservative', 'aggressive' + 'prefer_larger': True, # Prefer larger readable text + 'max_lines': 10, # Maximum lines before forcing smaller + 'line_spacing': 1.3, # Line height multiplier + 'bubble_size_factor': True # Scale font based on bubble size + }, + + # Mask dilation settings with new iteration controls + 'mask_dilation': 0, + 'dilation_kernel_size': 5, # Kernel size for dilation operations + 'use_all_iterations': True, # Master control - use same for all by default + 'all_iterations': 2, # Value when using same for all + 'text_bubble_dilation_iterations': 2, # Text-filled speech bubbles + 'empty_bubble_dilation_iterations': 3, # Empty speech bubbles + 'free_text_dilation_iterations': 0, # Free text (0 for clean B&W) + 'bubble_dilation_iterations': 2, # Legacy support + 'dilation_iterations': 2, # Legacy support + + # Cloud inpainting settings + 'cloud_inpaint_model': 'ideogram-v2', + 'cloud_custom_version': '', + 'cloud_inpaint_prompt': 'clean background, smooth surface', + 'cloud_negative_prompt': 'text, writing, letters', + 'cloud_inference_steps': 20, + 'cloud_timeout': 60 + } + + # Merge with existing config + self.settings = self._merge_settings(config.get('manga_settings', {})) + + # Show dialog + self.show_dialog() + + def _create_styled_checkbox(self, text): + """Create a checkbox with proper checkmark using text overlay (same as manga_integration.py)""" + checkbox = QCheckBox(text) + checkbox.setStyleSheet(""" + QCheckBox { + color: white; + spacing: 6px; + } + QCheckBox::indicator { + width: 14px; + height: 14px; + border: 1px solid #5a9fd4; + border-radius: 2px; + background-color: #2d2d2d; + } + QCheckBox::indicator:checked { + background-color: #5a9fd4; + border-color: #5a9fd4; + } + QCheckBox::indicator:hover { + border-color: #7bb3e0; + } + QCheckBox:disabled { + color: #666666; + } + QCheckBox::indicator:disabled { + background-color: #1a1a1a; + border-color: #3a3a3a; + } + """) + + # Create checkmark overlay + checkmark = QLabel("✓", checkbox) + checkmark.setStyleSheet(""" + QLabel { + color: white; + background: transparent; + font-weight: bold; + font-size: 11px; + } + """) + checkmark.setAlignment(Qt.AlignCenter) + checkmark.hide() + checkmark.setAttribute(Qt.WA_TransparentForMouseEvents) # Make checkmark click-through + + # Position checkmark properly after widget is shown + def position_checkmark(): + # Position over the checkbox indicator + checkmark.setGeometry(2, 1, 14, 14) + + # Show/hide checkmark based on checked state + def update_checkmark(): + if checkbox.isChecked(): + position_checkmark() + checkmark.show() + else: + checkmark.hide() + + checkbox.stateChanged.connect(update_checkmark) + # Delay initial positioning to ensure widget is properly rendered + QTimer.singleShot(0, lambda: (position_checkmark(), update_checkmark())) + + return checkbox + + def _disable_spinbox_scroll(self, widget): + """Disable mouse wheel scrolling on a spinbox, combobox, or slider (PySide6 version)""" + # Install event filter to block wheel events + class WheelEventFilter(QObject): + def eventFilter(self, obj, event): + if event.type() == QEvent.Wheel: + event.ignore() + return True + return False + + filter_obj = WheelEventFilter(widget) # Parent it to the widget + widget.installEventFilter(filter_obj) + # Store the filter so it doesn't get garbage collected + if not hasattr(widget, '_wheel_filter'): + widget._wheel_filter = filter_obj + + def _disable_all_spinbox_scrolling(self, parent): + """Recursively find and disable scrolling on all spinboxes, comboboxes, and sliders (PySide6 version)""" + # Check if the parent itself is a spinbox, combobox, or slider + if isinstance(parent, (QSpinBox, QDoubleSpinBox, QComboBox, QSlider)): + self._disable_spinbox_scroll(parent) + + # Check all children recursively + if hasattr(parent, 'children'): + for child in parent.children(): + if isinstance(child, QWidget): + self._disable_all_spinbox_scrolling(child) + + def _create_font_size_controls(self, parent_layout): + """Create improved font size controls with presets""" + + # Font size frame + font_frame = QWidget() + font_layout = QHBoxLayout(font_frame) + font_layout.setContentsMargins(0, 0, 0, 0) + parent_layout.addWidget(font_frame) + + label = QLabel("Font Size:") + label.setMinimumWidth(150) + font_layout.addWidget(label) + + # Font size mode selection + mode_frame = QWidget() + mode_layout = QHBoxLayout(mode_frame) + mode_layout.setContentsMargins(0, 0, 0, 0) + font_layout.addWidget(mode_frame) + + # Radio buttons for mode - using QButtonGroup + self.font_size_mode_group = QButtonGroup() + self.font_size_mode = 'auto' # Store mode as string attribute + + modes = [ + ("Auto", "auto", "Automatically fit text to bubble size"), + ("Fixed", "fixed", "Use a specific font size"), + ("Scale", "scale", "Scale auto size by percentage") + ] + + for text, value, tooltip in modes: + rb = QRadioButton(text) + rb.setChecked(value == 'auto') + rb.setToolTip(tooltip) + rb.toggled.connect(lambda checked, v=value: self._on_font_mode_change(v) if checked else None) + mode_layout.addWidget(rb) + self.font_size_mode_group.addButton(rb) + + # Controls frame (changes based on mode) + self.font_controls_frame = QWidget() + self.font_controls_layout = QVBoxLayout(self.font_controls_frame) + self.font_controls_layout.setContentsMargins(20, 5, 0, 5) + parent_layout.addWidget(self.font_controls_frame) + + # Fixed size controls + self.fixed_size_frame = QWidget() + fixed_layout = QHBoxLayout(self.fixed_size_frame) + fixed_layout.setContentsMargins(0, 0, 0, 0) + fixed_layout.addWidget(QLabel("Size:")) + + self.fixed_font_size_spin = QSpinBox() + self.fixed_font_size_spin.setRange(8, 72) + self.fixed_font_size_spin.setValue(16) + self.fixed_font_size_spin.valueChanged.connect(self._save_rendering_settings) + fixed_layout.addWidget(self.fixed_font_size_spin) + + # Quick presets for fixed size + fixed_layout.addWidget(QLabel("Presets:")) + + presets = [ + ("Small", 12), + ("Medium", 16), + ("Large", 20), + ("XL", 24) + ] + + for text, size in presets: + btn = QPushButton(text) + btn.setMaximumWidth(60) + btn.clicked.connect(lambda checked, s=size: self._set_fixed_size(s)) + fixed_layout.addWidget(btn) + + fixed_layout.addStretch() + + # Scale controls + self.scale_frame = QWidget() + scale_layout = QHBoxLayout(self.scale_frame) + scale_layout.setContentsMargins(0, 0, 0, 0) + scale_layout.addWidget(QLabel("Scale:")) + + # QSlider uses integers, so we'll use 50-200 to represent 0.5-2.0 + self.font_scale_slider = QSlider(Qt.Horizontal) + self.font_scale_slider.setRange(50, 200) + self.font_scale_slider.setValue(100) + self.font_scale_slider.setMinimumWidth(200) + self.font_scale_slider.valueChanged.connect(self._update_scale_label) + scale_layout.addWidget(self.font_scale_slider) + + self.scale_label = QLabel("100%") + self.scale_label.setMinimumWidth(50) + scale_layout.addWidget(self.scale_label) + + # Quick scale presets + scale_layout.addWidget(QLabel("Quick:")) + + scale_presets = [ + ("75%", 0.75), + ("100%", 1.0), + ("125%", 1.25), + ("150%", 1.5) + ] + + for text, scale in scale_presets: + btn = QPushButton(text) + btn.setMaximumWidth(50) + btn.clicked.connect(lambda checked, s=scale: self._set_scale(s)) + scale_layout.addWidget(btn) + + scale_layout.addStretch() + + # Auto size settings + self.auto_frame = QWidget() + auto_layout = QVBoxLayout(self.auto_frame) + auto_layout.setContentsMargins(0, 0, 0, 0) + + # Min/Max size constraints for auto mode + constraints_frame = QWidget() + constraints_layout = QHBoxLayout(constraints_frame) + constraints_layout.setContentsMargins(0, 0, 0, 0) + auto_layout.addWidget(constraints_frame) + + constraints_layout.addWidget(QLabel("Size Range:")) + + constraints_layout.addWidget(QLabel("Min:")) + self.min_font_size_spin = QSpinBox() + self.min_font_size_spin.setRange(6, 20) + self.min_font_size_spin.setValue(10) + self.min_font_size_spin.valueChanged.connect(self._save_rendering_settings) + constraints_layout.addWidget(self.min_font_size_spin) + + constraints_layout.addWidget(QLabel("Max:")) + self.max_font_size_spin = QSpinBox() + self.max_font_size_spin.setRange(16, 48) + self.max_font_size_spin.setValue(28) + self.max_font_size_spin.valueChanged.connect(self._save_rendering_settings) + constraints_layout.addWidget(self.max_font_size_spin) + + constraints_layout.addStretch() + + # Auto fit quality + quality_frame = QWidget() + quality_layout = QHBoxLayout(quality_frame) + quality_layout.setContentsMargins(0, 0, 0, 0) + auto_layout.addWidget(quality_frame) + + quality_layout.addWidget(QLabel("Fit Style:")) + + self.auto_fit_style_group = QButtonGroup() + self.auto_fit_style = 'balanced' # Store as string attribute + + fit_styles = [ + ("Compact", "compact", "Fit more text, smaller size"), + ("Balanced", "balanced", "Balance readability and fit"), + ("Readable", "readable", "Prefer larger, more readable text") + ] + + for text, value, tooltip in fit_styles: + rb = QRadioButton(text) + rb.setChecked(value == 'balanced') + rb.setToolTip(tooltip) + rb.toggled.connect(lambda checked, v=value: self._on_fit_style_change(v) if checked else None) + quality_layout.addWidget(rb) + self.auto_fit_style_group.addButton(rb) + + quality_layout.addStretch() + + # Initialize the correct frame + self._on_font_mode_change('auto') + + def _on_font_mode_change(self, mode): + """Show/hide appropriate font controls based on mode""" + # Update the stored mode + self.font_size_mode = mode + + # Remove all frames from layout + self.font_controls_layout.removeWidget(self.fixed_size_frame) + self.font_controls_layout.removeWidget(self.scale_frame) + self.font_controls_layout.removeWidget(self.auto_frame) + self.fixed_size_frame.hide() + self.scale_frame.hide() + self.auto_frame.hide() + + # Show the appropriate frame + if mode == 'fixed': + self.font_controls_layout.addWidget(self.fixed_size_frame) + self.fixed_size_frame.show() + elif mode == 'scale': + self.font_controls_layout.addWidget(self.scale_frame) + self.scale_frame.show() + else: # auto + self.font_controls_layout.addWidget(self.auto_frame) + self.auto_frame.show() + + self._save_rendering_settings() + + def _set_fixed_size(self, size): + """Set fixed font size from preset""" + self.fixed_font_size_spin.setValue(size) + self._save_rendering_settings() + + def _set_scale(self, scale): + """Set font scale from preset""" + # Scale is 0.5-2.0, slider uses 50-200 + self.font_scale_slider.setValue(int(scale * 100)) + self._update_scale_label() + self._save_rendering_settings() + + def _update_scale_label(self): + """Update the scale percentage label""" + # Get value from slider (50-200) and convert to percentage + scale_value = self.font_scale_slider.value() + self.scale_label.setText(f"{scale_value}%") + self._save_rendering_settings() + + def _on_fit_style_change(self, style): + """Handle fit style change""" + self.auto_fit_style = style + self._save_rendering_settings() + + def _create_tooltip(self, widget, text): + """Create a tooltip for a widget - PySide6 version""" + # In PySide6, tooltips are much simpler - just set the toolTip property + widget.setToolTip(text) + + def _merge_settings(self, existing: Dict) -> Dict: + """Merge existing settings with defaults""" + result = self.default_settings.copy() + + def deep_merge(base: Dict, update: Dict) -> Dict: + for key, value in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + base[key] = deep_merge(base[key], value) + else: + base[key] = value + return base + + return deep_merge(result, existing) + + def show_dialog(self): + """Display the settings dialog using PySide6""" + # Set initialization flag to prevent auto-saves during setup + self._initializing = True + + # Set dialog properties + self.setWindowTitle("Manga Translation Settings") + # Dialog is already non-modal from __init__, don't override it + + # Set the halgakos.ico icon + try: + icon_path = os.path.join(os.path.dirname(__file__), 'Halgakos.ico') + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + except Exception: + pass # Fail silently if icon can't be loaded + + # Apply overall dark theme styling + self.setStyleSheet(""" + QDialog { + background-color: #1e1e1e; + color: white; + font-family: Arial; + } + QGroupBox { + font-family: Arial; + font-size: 10pt; + font-weight: bold; + color: white; + border: 1px solid #555; + border-radius: 5px; + margin-top: 10px; + padding-top: 5px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + } + QLabel { + color: white; + font-family: Arial; + font-size: 9pt; + } + QLineEdit { + background-color: #2d2d2d; + color: white; + border: 1px solid #555; + border-radius: 3px; + padding: 3px; + font-family: Arial; + font-size: 9pt; + } + QSpinBox, QDoubleSpinBox { + background-color: #2d2d2d; + color: white; + border: 1px solid #555; + border-radius: 3px; + padding: 3px; + font-family: Arial; + font-size: 9pt; + } + QComboBox { + background-color: #2d2d2d; + color: white; + border: 1px solid #555; + border-radius: 3px; + padding: 3px 5px; + padding-right: 25px; + font-family: Arial; + font-size: 9pt; + min-height: 20px; + } + QComboBox:hover { + border: 1px solid #7bb3e0; + } + QComboBox:focus { + border: 1px solid #5a9fd4; + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: right center; + width: 20px; + border-left: 1px solid #555; + background-color: #3c3c3c; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + } + QComboBox::drop-down:hover { + background-color: #4a4a4a; + } + QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #aaa; + width: 0; + height: 0; + margin-right: 5px; + } + QComboBox::down-arrow:hover { + border-top: 5px solid #fff; + } + QComboBox QAbstractItemView { + background-color: #2d2d2d; + color: white; + selection-background-color: #5a9fd4; + selection-color: white; + border: 1px solid #555; + outline: none; + } + QPushButton { + font-family: Arial; + font-size: 9pt; + padding: 5px 15px; + border-radius: 3px; + border: none; + } + QSlider::groove:horizontal { + border: 1px solid #555; + height: 6px; + background: #2d2d2d; + border-radius: 3px; + } + QSlider::handle:horizontal { + background: #5a9fd4; + border: 1px solid #5a9fd4; + width: 18px; + border-radius: 9px; + margin: -6px 0; + } + QSlider::handle:horizontal:hover { + background: #7bb3e0; + border: 1px solid #7bb3e0; + } + QRadioButton { + color: white; + spacing: 6px; + font-family: Arial; + font-size: 9pt; + } + QRadioButton::indicator { + width: 16px; + height: 16px; + border: 2px solid #5a9fd4; + border-radius: 8px; + background-color: #2d2d2d; + } + QRadioButton::indicator:checked { + background-color: #5a9fd4; + border: 2px solid #5a9fd4; + } + QRadioButton::indicator:hover { + border-color: #7bb3e0; + } + QRadioButton:disabled { + color: #666666; + } + QRadioButton::indicator:disabled { + background-color: #1a1a1a; + border-color: #3a3a3a; + } + QCheckBox { + color: white; + spacing: 6px; + } + QCheckBox::indicator { + width: 14px; + height: 14px; + border: 1px solid #5a9fd4; + border-radius: 2px; + background-color: #2d2d2d; + } + QCheckBox::indicator:checked { + background-color: #5a9fd4; + border-color: #5a9fd4; + } + QCheckBox::indicator:hover { + border-color: #7bb3e0; + } + QCheckBox:disabled { + color: #666666; + } + QCheckBox::indicator:disabled { + background-color: #1a1a1a; + border-color: #3a3a3a; + } + QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { + background-color: #1a1a1a; + color: #666666; + border: 1px solid #3a3a3a; + } + QLabel:disabled { + color: #666666; + } + QScrollArea { + background-color: #1e1e1e; + border: none; + } + QWidget { + background-color: #1e1e1e; + color: white; + } + """) + + # Calculate size based on screen dimensions + # Use availableGeometry to exclude taskbar and other system UI + app = QApplication.instance() + if app: + screen = app.primaryScreen().availableGeometry() + else: + screen = self.parent.screen().availableGeometry() if self.parent else self.screen().availableGeometry() + + dialog_width = min(800, int(screen.width() * 0.5)) + dialog_height = int(screen.height() * 0.90) # Use 90% of available height for more screen space with safety margin + self.resize(dialog_width, dialog_height) + + # Center the dialog within available screen space + dialog_x = screen.x() + (screen.width() - dialog_width) // 2 + dialog_y = screen.y() + (screen.height() - dialog_height) // 2 + self.move(dialog_x, dialog_y) + + # Create main layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(10) + + # Create scroll area for the content + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Create content widget that will go inside scroll area + content_widget = QWidget() + content_layout = QVBoxLayout(content_widget) + content_layout.setContentsMargins(5, 5, 5, 5) + + # Create tab widget with enhanced styling + self.tab_widget = QTabWidget() + self.tab_widget.setStyleSheet(""" + QTabWidget::pane { + border: 1px solid #555; + background-color: #2b2b2b; + } + QTabBar::tab { + background-color: #3c3c3c; + color: #cccccc; + border: 1px solid #555; + border-bottom: none; + padding: 8px 16px; + margin-right: 2px; + font-family: Arial; + font-size: 10pt; + font-weight: bold; + } + QTabBar::tab:selected { + background-color: #5a9fd4; + color: white; + border-color: #7bb3e0; + margin-bottom: -1px; + } + QTabBar::tab:hover:!selected { + background-color: #4a4a4a; + color: white; + border-color: #7bb3e0; + } + QTabBar::tab:first { + margin-left: 0; + } + """) + content_layout.addWidget(self.tab_widget) + + # Create all tabs + self._create_preprocessing_tab() + self._create_ocr_tab() + self._create_inpainting_tab() + self._create_advanced_tab() + self._create_cloud_api_tab() + # NOTE: Font Sizing tab removed; controls are now in Manga Integration UI + + # Set content widget in scroll area + scroll_area.setWidget(content_widget) + main_layout.addWidget(scroll_area) + + # Create button frame at bottom + button_frame = QWidget() + button_layout = QHBoxLayout(button_frame) + button_layout.setContentsMargins(0, 5, 0, 0) + + # Reset button on left + reset_button = QPushButton("Reset to Defaults") + reset_button.clicked.connect(self._reset_defaults) + reset_button.setMinimumWidth(120) + reset_button.setMinimumHeight(32) + reset_button.setStyleSheet(""" + QPushButton { + background-color: #ffc107; + color: #1a1a1a; + font-weight: bold; + font-size: 10pt; + border: none; + border-radius: 4px; + padding: 6px 16px; + } + QPushButton:hover { + background-color: #ffcd38; + } + QPushButton:pressed { + background-color: #e0a800; + } + """) + button_layout.addWidget(reset_button) + + button_layout.addStretch() # Push other buttons to the right + + # Cancel and Save buttons on right + cancel_button = QPushButton("Cancel") + cancel_button.clicked.connect(self._cancel) + cancel_button.setMinimumWidth(90) + cancel_button.setMinimumHeight(32) + cancel_button.setStyleSheet(""" + QPushButton { + background-color: #6c757d; + color: white; + font-weight: bold; + font-size: 10pt; + border: none; + border-radius: 4px; + padding: 6px 16px; + } + QPushButton:hover { + background-color: #7d8a96; + } + QPushButton:pressed { + background-color: #5a6268; + } + """) + button_layout.addWidget(cancel_button) + + save_button = QPushButton("Save") + save_button.clicked.connect(self._save_settings) + save_button.setDefault(True) # Make it the default button + save_button.setMinimumWidth(90) + save_button.setMinimumHeight(32) + save_button.setStyleSheet(""" + QPushButton { + background-color: #28a745; + color: white; + font-weight: bold; + font-size: 10pt; + border: none; + border-radius: 4px; + padding: 6px 16px; + } + QPushButton:hover { + background-color: #34c759; + } + QPushButton:pressed { + background-color: #218838; + } + """) + button_layout.addWidget(save_button) + + main_layout.addWidget(button_frame) + + # Clear initialization flag after setup is complete + self._initializing = False + + # Initialize preprocessing state + self._toggle_preprocessing() + + # Initialize tiling controls state (must be after widgets are created) + try: + self._toggle_tiling_controls() + except Exception as e: + print(f"Warning: Failed to initialize tiling controls: {e}") + + # Initialize iteration controls state + try: + self._toggle_iteration_controls() + except Exception: + pass + + # Disable mouse wheel scrolling on all spinboxes and comboboxes + self._disable_all_spinbox_scrolling(self) + + # Show the dialog + self.show() + + def _create_preprocessing_tab(self): + """Create preprocessing settings tab with all options""" + # Create tab widget and add to tab widget + tab_widget = QWidget() + self.tab_widget.addTab(tab_widget, "Preprocessing") + + # Main scrollable content + main_layout = QVBoxLayout(tab_widget) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.setSpacing(6) + + # Enable preprocessing group + enable_group = QGroupBox("Image Preprocessing") + main_layout.addWidget(enable_group) + enable_layout = QVBoxLayout(enable_group) + enable_layout.setContentsMargins(8, 8, 8, 6) + enable_layout.setSpacing(4) + + self.preprocess_enabled = self._create_styled_checkbox("Enable Image Preprocessing") + self.preprocess_enabled.setChecked(self.settings['preprocessing']['enabled']) + self.preprocess_enabled.toggled.connect(self._toggle_preprocessing) + enable_layout.addWidget(self.preprocess_enabled) + + # Store all preprocessing controls for enable/disable + self.preprocessing_controls = [] + + # Auto quality detection + self.auto_detect = self._create_styled_checkbox("Auto-detect image quality issues") + self.auto_detect.setChecked(self.settings['preprocessing']['auto_detect_quality']) + enable_layout.addWidget(self.auto_detect) + self.preprocessing_controls.append(self.auto_detect) + + # Quality thresholds section + threshold_group = QGroupBox("Image Enhancement") + main_layout.addWidget(threshold_group) + threshold_layout = QVBoxLayout(threshold_group) + threshold_layout.setContentsMargins(8, 8, 8, 6) + threshold_layout.setSpacing(4) + self.preprocessing_controls.append(threshold_group) + + # Contrast threshold + contrast_frame = QWidget() + contrast_layout = QHBoxLayout(contrast_frame) + contrast_layout.setContentsMargins(0, 0, 0, 0) + threshold_layout.addWidget(contrast_frame) + + contrast_label = QLabel("Contrast Adjustment:") + contrast_label.setMinimumWidth(150) + contrast_layout.addWidget(contrast_label) + self.preprocessing_controls.append(contrast_label) + + self.contrast_threshold = QDoubleSpinBox() + self.contrast_threshold.setRange(0.0, 1.0) + self.contrast_threshold.setSingleStep(0.01) + self.contrast_threshold.setDecimals(2) + self.contrast_threshold.setValue(self.settings['preprocessing']['contrast_threshold']) + contrast_layout.addWidget(self.contrast_threshold) + self.preprocessing_controls.append(self.contrast_threshold) + contrast_layout.addStretch() + + # Sharpness threshold + sharpness_frame = QWidget() + sharpness_layout = QHBoxLayout(sharpness_frame) + sharpness_layout.setContentsMargins(0, 0, 0, 0) + threshold_layout.addWidget(sharpness_frame) + + sharpness_label = QLabel("Sharpness Enhancement:") + sharpness_label.setMinimumWidth(150) + sharpness_layout.addWidget(sharpness_label) + self.preprocessing_controls.append(sharpness_label) + + self.sharpness_threshold = QDoubleSpinBox() + self.sharpness_threshold.setRange(0.0, 1.0) + self.sharpness_threshold.setSingleStep(0.01) + self.sharpness_threshold.setDecimals(2) + self.sharpness_threshold.setValue(self.settings['preprocessing']['sharpness_threshold']) + sharpness_layout.addWidget(self.sharpness_threshold) + self.preprocessing_controls.append(self.sharpness_threshold) + sharpness_layout.addStretch() + + # Enhancement strength + enhance_frame = QWidget() + enhance_layout = QHBoxLayout(enhance_frame) + enhance_layout.setContentsMargins(0, 0, 0, 0) + threshold_layout.addWidget(enhance_frame) + + enhance_label = QLabel("Overall Enhancement:") + enhance_label.setMinimumWidth(150) + enhance_layout.addWidget(enhance_label) + self.preprocessing_controls.append(enhance_label) + + self.enhancement_strength = QDoubleSpinBox() + self.enhancement_strength.setRange(0.0, 3.0) + self.enhancement_strength.setSingleStep(0.01) + self.enhancement_strength.setDecimals(2) + self.enhancement_strength.setValue(self.settings['preprocessing']['enhancement_strength']) + enhance_layout.addWidget(self.enhancement_strength) + self.preprocessing_controls.append(self.enhancement_strength) + enhance_layout.addStretch() + + # Noise reduction section + noise_group = QGroupBox("Noise Reduction") + main_layout.addWidget(noise_group) + noise_layout = QVBoxLayout(noise_group) + noise_layout.setContentsMargins(8, 8, 8, 6) + noise_layout.setSpacing(4) + self.preprocessing_controls.append(noise_group) + + # Noise threshold + noise_threshold_frame = QWidget() + noise_threshold_layout = QHBoxLayout(noise_threshold_frame) + noise_threshold_layout.setContentsMargins(0, 0, 0, 0) + noise_layout.addWidget(noise_threshold_frame) + + noise_label = QLabel("Noise Threshold:") + noise_label.setMinimumWidth(150) + noise_threshold_layout.addWidget(noise_label) + self.preprocessing_controls.append(noise_label) + + self.noise_threshold = QSpinBox() + self.noise_threshold.setRange(0, 50) + self.noise_threshold.setValue(self.settings['preprocessing']['noise_threshold']) + noise_threshold_layout.addWidget(self.noise_threshold) + self.preprocessing_controls.append(self.noise_threshold) + noise_threshold_layout.addStretch() + + # Denoise strength + denoise_frame = QWidget() + denoise_layout = QHBoxLayout(denoise_frame) + denoise_layout.setContentsMargins(0, 0, 0, 0) + noise_layout.addWidget(denoise_frame) + + denoise_label = QLabel("Denoise Strength:") + denoise_label.setMinimumWidth(150) + denoise_layout.addWidget(denoise_label) + self.preprocessing_controls.append(denoise_label) + + self.denoise_strength = QSpinBox() + self.denoise_strength.setRange(0, 30) + self.denoise_strength.setValue(self.settings['preprocessing']['denoise_strength']) + denoise_layout.addWidget(self.denoise_strength) + self.preprocessing_controls.append(self.denoise_strength) + denoise_layout.addStretch() + + # Size limits section + size_group = QGroupBox("Image Size Limits") + main_layout.addWidget(size_group) + size_layout = QVBoxLayout(size_group) + size_layout.setContentsMargins(8, 8, 8, 6) + size_layout.setSpacing(4) + self.preprocessing_controls.append(size_group) + + # Max dimension + dimension_frame = QWidget() + dimension_layout = QHBoxLayout(dimension_frame) + dimension_layout.setContentsMargins(0, 0, 0, 0) + size_layout.addWidget(dimension_frame) + + dimension_label = QLabel("Max Dimension:") + dimension_label.setMinimumWidth(150) + dimension_layout.addWidget(dimension_label) + self.preprocessing_controls.append(dimension_label) + + self.dimension_spinbox = QSpinBox() + self.dimension_spinbox.setRange(500, 4000) + self.dimension_spinbox.setSingleStep(100) + self.dimension_spinbox.setValue(self.settings['preprocessing']['max_image_dimension']) + dimension_layout.addWidget(self.dimension_spinbox) + self.preprocessing_controls.append(self.dimension_spinbox) + + dimension_layout.addWidget(QLabel("pixels")) + dimension_layout.addStretch() + + # Max pixels + pixels_frame = QWidget() + pixels_layout = QHBoxLayout(pixels_frame) + pixels_layout.setContentsMargins(0, 0, 0, 0) + size_layout.addWidget(pixels_frame) + + pixels_label = QLabel("Max Total Pixels:") + pixels_label.setMinimumWidth(150) + pixels_layout.addWidget(pixels_label) + self.preprocessing_controls.append(pixels_label) + + self.pixels_spinbox = QSpinBox() + self.pixels_spinbox.setRange(1000000, 10000000) + self.pixels_spinbox.setSingleStep(100000) + self.pixels_spinbox.setValue(self.settings['preprocessing']['max_image_pixels']) + pixels_layout.addWidget(self.pixels_spinbox) + self.preprocessing_controls.append(self.pixels_spinbox) + + pixels_layout.addWidget(QLabel("pixels")) + pixels_layout.addStretch() + + # Compression section + compression_group = QGroupBox("Image Compression (applies to OCR uploads)") + main_layout.addWidget(compression_group) + compression_layout = QVBoxLayout(compression_group) + compression_layout.setContentsMargins(8, 8, 8, 6) + compression_layout.setSpacing(4) + # Do NOT add compression controls to preprocessing_controls; keep independent of preprocessing toggle + + # Enable compression toggle + self.compression_enabled = self._create_styled_checkbox("Enable compression for OCR uploads") + self.compression_enabled.setChecked(self.settings.get('compression', {}).get('enabled', False)) + self.compression_enabled.toggled.connect(self._toggle_compression_enabled) + compression_layout.addWidget(self.compression_enabled) + + # Format selection + format_frame = QWidget() + format_layout = QHBoxLayout(format_frame) + format_layout.setContentsMargins(0, 0, 0, 0) + compression_layout.addWidget(format_frame) + + self.format_label = QLabel("Format:") + self.format_label.setMinimumWidth(150) + format_layout.addWidget(self.format_label) + + self.compression_format_combo = QComboBox() + self.compression_format_combo.addItems(['jpeg', 'png', 'webp']) + self.compression_format_combo.setCurrentText(self.settings.get('compression', {}).get('format', 'jpeg')) + self.compression_format_combo.currentTextChanged.connect(self._toggle_compression_format) + format_layout.addWidget(self.compression_format_combo) + format_layout.addStretch() + + # JPEG quality + self.jpeg_frame = QWidget() + jpeg_layout = QHBoxLayout(self.jpeg_frame) + jpeg_layout.setContentsMargins(0, 0, 0, 0) + compression_layout.addWidget(self.jpeg_frame) + + self.jpeg_label = QLabel("JPEG Quality:") + self.jpeg_label.setMinimumWidth(150) + jpeg_layout.addWidget(self.jpeg_label) + + self.jpeg_quality_spin = QSpinBox() + self.jpeg_quality_spin.setRange(1, 95) + self.jpeg_quality_spin.setValue(self.settings.get('compression', {}).get('jpeg_quality', 85)) + jpeg_layout.addWidget(self.jpeg_quality_spin) + + self.jpeg_help = QLabel("(higher = better quality, larger size)") + self.jpeg_help.setStyleSheet("color: gray; font-size: 9pt;") + jpeg_layout.addWidget(self.jpeg_help) + jpeg_layout.addStretch() + + # PNG compression level + self.png_frame = QWidget() + png_layout = QHBoxLayout(self.png_frame) + png_layout.setContentsMargins(0, 0, 0, 0) + compression_layout.addWidget(self.png_frame) + + self.png_label = QLabel("PNG Compression:") + self.png_label.setMinimumWidth(150) + png_layout.addWidget(self.png_label) + + self.png_level_spin = QSpinBox() + self.png_level_spin.setRange(0, 9) + self.png_level_spin.setValue(self.settings.get('compression', {}).get('png_compress_level', 6)) + png_layout.addWidget(self.png_level_spin) + + self.png_help = QLabel("(0 = fastest, 9 = smallest)") + self.png_help.setStyleSheet("color: gray; font-size: 9pt;") + png_layout.addWidget(self.png_help) + png_layout.addStretch() + + # WEBP quality + self.webp_frame = QWidget() + webp_layout = QHBoxLayout(self.webp_frame) + webp_layout.setContentsMargins(0, 0, 0, 0) + compression_layout.addWidget(self.webp_frame) + + self.webp_label = QLabel("WEBP Quality:") + self.webp_label.setMinimumWidth(150) + webp_layout.addWidget(self.webp_label) + + self.webp_quality_spin = QSpinBox() + self.webp_quality_spin.setRange(1, 100) + self.webp_quality_spin.setValue(self.settings.get('compression', {}).get('webp_quality', 85)) + webp_layout.addWidget(self.webp_quality_spin) + + self.webp_help = QLabel("(higher = better quality, larger size)") + self.webp_help.setStyleSheet("color: gray; font-size: 9pt;") + webp_layout.addWidget(self.webp_help) + webp_layout.addStretch() + + # Initialize format-specific visibility and enabled state + self._toggle_compression_format() + self._toggle_compression_enabled() + + # HD Strategy (Inpainting acceleration) - Independent of preprocessing toggle + hd_group = QGroupBox("Inpainting HD Strategy") + main_layout.addWidget(hd_group) + hd_layout = QVBoxLayout(hd_group) + hd_layout.setContentsMargins(8, 8, 8, 6) + hd_layout.setSpacing(4) + # Do NOT add to preprocessing_controls - HD Strategy should be independent + + # Chunk settings for large images - Independent of preprocessing toggle + chunk_group = QGroupBox("Large Image Processing") + main_layout.addWidget(chunk_group) + chunk_layout = QVBoxLayout(chunk_group) + chunk_layout.setContentsMargins(8, 8, 8, 6) + chunk_layout.setSpacing(4) + # Do NOT add to preprocessing_controls - Large Image Processing should be independent + + # Strategy selector + strat_frame = QWidget() + strat_layout = QHBoxLayout(strat_frame) + strat_layout.setContentsMargins(0, 0, 0, 0) + hd_layout.addWidget(strat_frame) + + strat_label = QLabel("Strategy:") + strat_label.setMinimumWidth(150) + strat_layout.addWidget(strat_label) + + self.hd_strategy_combo = QComboBox() + self.hd_strategy_combo.addItems(['original', 'resize', 'crop']) + self.hd_strategy_combo.setCurrentText(self.settings.get('advanced', {}).get('hd_strategy', 'resize')) + self.hd_strategy_combo.currentTextChanged.connect(self._on_hd_strategy_change) + strat_layout.addWidget(self.hd_strategy_combo) + + strat_help = QLabel("(original = legacy full-image; resize/crop = faster)") + strat_help.setStyleSheet("color: gray; font-size: 9pt;") + strat_layout.addWidget(strat_help) + strat_layout.addStretch() + + # Resize limit row + self.hd_resize_frame = QWidget() + resize_layout = QHBoxLayout(self.hd_resize_frame) + resize_layout.setContentsMargins(0, 0, 0, 0) + hd_layout.addWidget(self.hd_resize_frame) + + resize_label = QLabel("Resize limit (long edge):") + resize_label.setMinimumWidth(150) + resize_layout.addWidget(resize_label) + + self.hd_resize_limit_spin = QSpinBox() + self.hd_resize_limit_spin.setRange(512, 4096) + self.hd_resize_limit_spin.setSingleStep(64) + self.hd_resize_limit_spin.setValue(int(self.settings.get('advanced', {}).get('hd_strategy_resize_limit', 1536))) + resize_layout.addWidget(self.hd_resize_limit_spin) + + resize_layout.addWidget(QLabel("px")) + resize_layout.addStretch() + + # Crop params rows + self.hd_crop_margin_frame = QWidget() + margin_layout = QHBoxLayout(self.hd_crop_margin_frame) + margin_layout.setContentsMargins(0, 0, 0, 0) + hd_layout.addWidget(self.hd_crop_margin_frame) + + margin_label = QLabel("Crop margin:") + margin_label.setMinimumWidth(150) + margin_layout.addWidget(margin_label) + + self.hd_crop_margin_spin = QSpinBox() + self.hd_crop_margin_spin.setRange(0, 256) + self.hd_crop_margin_spin.setSingleStep(2) + self.hd_crop_margin_spin.setValue(int(self.settings.get('advanced', {}).get('hd_strategy_crop_margin', 16))) + margin_layout.addWidget(self.hd_crop_margin_spin) + + margin_layout.addWidget(QLabel("px")) + margin_layout.addStretch() + + self.hd_crop_trigger_frame = QWidget() + trigger_layout = QHBoxLayout(self.hd_crop_trigger_frame) + trigger_layout.setContentsMargins(0, 0, 0, 0) + hd_layout.addWidget(self.hd_crop_trigger_frame) + + trigger_label = QLabel("Crop trigger size:") + trigger_label.setMinimumWidth(150) + trigger_layout.addWidget(trigger_label) + + self.hd_crop_trigger_spin = QSpinBox() + self.hd_crop_trigger_spin.setRange(256, 4096) + self.hd_crop_trigger_spin.setSingleStep(64) + self.hd_crop_trigger_spin.setValue(int(self.settings.get('advanced', {}).get('hd_strategy_crop_trigger_size', 1024))) + trigger_layout.addWidget(self.hd_crop_trigger_spin) + + trigger_help = QLabel("px (apply crop only if long edge > trigger)") + trigger_layout.addWidget(trigger_help) + trigger_layout.addStretch() + + # Initialize strategy-specific visibility + self._on_hd_strategy_change() + + # Clarifying note about precedence with tiling + note_label = QLabel( + "Note: HD Strategy (resize/crop) takes precedence over Inpainting Tiling when it triggers.\n" + "Set strategy to 'original' if you want tiling to control large-image behavior." + ) + note_label.setStyleSheet("color: gray; font-size: 9pt;") + note_label.setWordWrap(True) + hd_layout.addWidget(note_label) + + # Chunk height + chunk_height_frame = QWidget() + chunk_height_layout = QHBoxLayout(chunk_height_frame) + chunk_height_layout.setContentsMargins(0, 0, 0, 0) + chunk_layout.addWidget(chunk_height_frame) + + chunk_height_label = QLabel("Chunk Height:") + chunk_height_label.setMinimumWidth(150) + chunk_height_layout.addWidget(chunk_height_label) + # Do NOT add to preprocessing_controls - chunk settings should be independent + + self.chunk_height_spinbox = QSpinBox() + self.chunk_height_spinbox.setRange(500, 2000) + self.chunk_height_spinbox.setSingleStep(100) + self.chunk_height_spinbox.setValue(self.settings['preprocessing']['chunk_height']) + chunk_height_layout.addWidget(self.chunk_height_spinbox) + # Do NOT add to preprocessing_controls - chunk settings should be independent + + chunk_height_layout.addWidget(QLabel("pixels")) + chunk_height_layout.addStretch() + + # Chunk overlap + chunk_overlap_frame = QWidget() + chunk_overlap_layout = QHBoxLayout(chunk_overlap_frame) + chunk_overlap_layout.setContentsMargins(0, 0, 0, 0) + chunk_layout.addWidget(chunk_overlap_frame) + + chunk_overlap_label = QLabel("Chunk Overlap:") + chunk_overlap_label.setMinimumWidth(150) + chunk_overlap_layout.addWidget(chunk_overlap_label) + # Do NOT add to preprocessing_controls - chunk settings should be independent + + self.chunk_overlap_spinbox = QSpinBox() + self.chunk_overlap_spinbox.setRange(0, 200) + self.chunk_overlap_spinbox.setSingleStep(10) + self.chunk_overlap_spinbox.setValue(self.settings['preprocessing']['chunk_overlap']) + chunk_overlap_layout.addWidget(self.chunk_overlap_spinbox) + # Do NOT add to preprocessing_controls - chunk settings should be independent + + chunk_overlap_layout.addWidget(QLabel("pixels")) + chunk_overlap_layout.addStretch() + + # Inpainting Tiling section + tiling_group = QGroupBox("Inpainting Tiling") + main_layout.addWidget(tiling_group) + tiling_layout = QVBoxLayout(tiling_group) + tiling_layout.setContentsMargins(8, 8, 8, 6) + tiling_layout.setSpacing(4) + # Do NOT add to preprocessing_controls - tiling should be independent + + # Enable tiling + # Prefer values from legacy 'tiling' section if present, otherwise use 'preprocessing' + tiling_enabled_value = self.settings['preprocessing'].get('inpaint_tiling_enabled', False) + if 'tiling' in self.settings and isinstance(self.settings['tiling'], dict) and 'enabled' in self.settings['tiling']: + tiling_enabled_value = self.settings['tiling']['enabled'] + + self.inpaint_tiling_enabled = self._create_styled_checkbox("Enable automatic tiling for inpainting (processes large images in tiles)") + self.inpaint_tiling_enabled.setChecked(tiling_enabled_value) + self.inpaint_tiling_enabled.toggled.connect(self._toggle_tiling_controls) + tiling_layout.addWidget(self.inpaint_tiling_enabled) + + # Tile size + tile_size_frame = QWidget() + tile_size_layout = QHBoxLayout(tile_size_frame) + tile_size_layout.setContentsMargins(0, 0, 0, 0) + tiling_layout.addWidget(tile_size_frame) + + self.tile_size_label = QLabel("Tile Size:") + self.tile_size_label.setMinimumWidth(150) + tile_size_layout.addWidget(self.tile_size_label) + + tile_size_value = self.settings['preprocessing'].get('inpaint_tile_size', 512) + if 'tiling' in self.settings and isinstance(self.settings['tiling'], dict) and 'tile_size' in self.settings['tiling']: + tile_size_value = self.settings['tiling']['tile_size'] + + self.tile_size_spinbox = QSpinBox() + self.tile_size_spinbox.setRange(256, 2048) + self.tile_size_spinbox.setSingleStep(128) + self.tile_size_spinbox.setValue(tile_size_value) + tile_size_layout.addWidget(self.tile_size_spinbox) + + self.tile_size_unit_label = QLabel("pixels") + tile_size_layout.addWidget(self.tile_size_unit_label) + tile_size_layout.addStretch() + + # Tile overlap + tile_overlap_frame = QWidget() + tile_overlap_layout = QHBoxLayout(tile_overlap_frame) + tile_overlap_layout.setContentsMargins(0, 0, 0, 0) + tiling_layout.addWidget(tile_overlap_frame) + + self.tile_overlap_label = QLabel("Tile Overlap:") + self.tile_overlap_label.setMinimumWidth(150) + tile_overlap_layout.addWidget(self.tile_overlap_label) + + tile_overlap_value = self.settings['preprocessing'].get('inpaint_tile_overlap', 64) + if 'tiling' in self.settings and isinstance(self.settings['tiling'], dict) and 'tile_overlap' in self.settings['tiling']: + tile_overlap_value = self.settings['tiling']['tile_overlap'] + + self.tile_overlap_spinbox = QSpinBox() + self.tile_overlap_spinbox.setRange(0, 256) + self.tile_overlap_spinbox.setSingleStep(16) + self.tile_overlap_spinbox.setValue(tile_overlap_value) + tile_overlap_layout.addWidget(self.tile_overlap_spinbox) + + self.tile_overlap_unit_label = QLabel("pixels") + tile_overlap_layout.addWidget(self.tile_overlap_unit_label) + tile_overlap_layout.addStretch() + + # Don't initialize here - will be done after dialog is shown + + # ELIMINATE ALL EMPTY SPACE - Add stretch at the end + main_layout.addStretch() + + def _create_inpainting_tab(self): + """Create inpainting settings tab with comprehensive per-text-type dilation controls""" + # Create tab widget and add to tab widget + tab_widget = QWidget() + self.tab_widget.addTab(tab_widget, "Inpainting") + + # Main scrollable content + main_layout = QVBoxLayout(tab_widget) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.setSpacing(6) + + # General Mask Settings (applies to all inpainting methods) + mask_group = QGroupBox("Mask Settings") + main_layout.addWidget(mask_group) + mask_layout = QVBoxLayout(mask_group) + mask_layout.setContentsMargins(8, 8, 8, 6) + mask_layout.setSpacing(4) + + # Auto toggle (affects both mask dilation AND iterations) + self.auto_iterations_checkbox = self._create_styled_checkbox("Auto Iterations (automatically set values based on OCR provider and B&W vs Color)") + self.auto_iterations_checkbox.setChecked(self.settings.get('auto_iterations', True)) + self.auto_iterations_checkbox.toggled.connect(self._toggle_iteration_controls) + self.auto_iterations_checkbox.toggled.connect(self._on_primary_auto_toggle) # Sync with "Use Same For All" + mask_layout.addWidget(self.auto_iterations_checkbox) + + # Mask Dilation frame (affected by auto setting) + mask_dilation_group = QGroupBox("Mask Dilation") + mask_layout.addWidget(mask_dilation_group) + mask_dilation_layout = QVBoxLayout(mask_dilation_group) + mask_dilation_layout.setContentsMargins(8, 8, 8, 6) + mask_dilation_layout.setSpacing(4) + + # Note about dilation importance + note_label = QLabel( + "Mask dilation is critical for avoiding white spots in final images.\n" + "Adjust per text type for optimal results." + ) + note_label.setStyleSheet("color: gray; font-style: italic;") + note_label.setWordWrap(True) + mask_dilation_layout.addWidget(note_label) + + # Keep all three dilation controls in a list for easy access + if not hasattr(self, 'mask_dilation_controls'): + self.mask_dilation_controls = [] + + # Mask dilation size + dilation_frame = QWidget() + dilation_layout = QHBoxLayout(dilation_frame) + dilation_layout.setContentsMargins(0, 0, 0, 0) + mask_dilation_layout.addWidget(dilation_frame) + + self.dilation_label = QLabel("Mask Dilation:") + self.dilation_label.setMinimumWidth(150) + dilation_layout.addWidget(self.dilation_label) + + self.mask_dilation_spinbox = QSpinBox() + self.mask_dilation_spinbox.setRange(0, 50) + self.mask_dilation_spinbox.setSingleStep(5) + self.mask_dilation_spinbox.setValue(self.settings.get('mask_dilation', 15)) + dilation_layout.addWidget(self.mask_dilation_spinbox) + + self.dilation_unit_label = QLabel("pixels (expand mask beyond text)") + dilation_layout.addWidget(self.dilation_unit_label) + dilation_layout.addStretch() + + # Kernel size + kernel_frame = QWidget() + kernel_layout = QHBoxLayout(kernel_frame) + kernel_layout.setContentsMargins(0, 0, 0, 0) + mask_dilation_layout.addWidget(kernel_frame) + + self.kernel_size_label = QLabel("Kernel Size:") + self.kernel_size_label.setMinimumWidth(150) + kernel_layout.addWidget(self.kernel_size_label) + + self.kernel_size_spinbox = QSpinBox() + self.kernel_size_spinbox.setRange(3, 15) + self.kernel_size_spinbox.setSingleStep(2) # Only odd numbers + self.kernel_size_spinbox.setValue(self.settings.get('dilation_kernel_size', 5)) + kernel_layout.addWidget(self.kernel_size_spinbox) + + self.kernel_size_unit_label = QLabel("pixels (dilation kernel size, must be odd)") + kernel_layout.addWidget(self.kernel_size_unit_label) + kernel_layout.addStretch() + + # Per-Text-Type Iterations - EXPANDED SECTION + iterations_group = QGroupBox("Dilation Iterations Control") + iterations_layout = QVBoxLayout(iterations_group) + mask_dilation_layout.addWidget(iterations_group) + + # All Iterations Master Control (NEW) + all_iter_widget = QWidget() + all_iter_layout = QHBoxLayout(all_iter_widget) + all_iter_layout.setContentsMargins(0, 0, 0, 0) + iterations_layout.addWidget(all_iter_widget) + + # Checkbox to enable/disable uniform iterations + self.use_all_iterations_checkbox = self._create_styled_checkbox("Use Same For All:") + self.use_all_iterations_checkbox.setChecked(self.settings.get('use_all_iterations', True)) + self.use_all_iterations_checkbox.toggled.connect(self._toggle_iteration_controls) + all_iter_layout.addWidget(self.use_all_iterations_checkbox) + + all_iter_layout.addSpacing(10) + + self.all_iterations_spinbox = QSpinBox() + self.all_iterations_spinbox.setRange(0, 5) + self.all_iterations_spinbox.setValue(self.settings.get('all_iterations', 2)) + self.all_iterations_spinbox.setEnabled(self.use_all_iterations_checkbox.isChecked()) + all_iter_layout.addWidget(self.all_iterations_spinbox) + + self.all_iter_label = QLabel("iterations (applies to all text types)") + all_iter_layout.addWidget(self.all_iter_label) + all_iter_layout.addStretch() + + # Separator + separator1 = QFrame() + separator1.setFrameShape(QFrame.Shape.HLine) + separator1.setFrameShadow(QFrame.Shadow.Sunken) + iterations_layout.addWidget(separator1) + + # Individual Controls Label + self.individual_controls_header_label = QLabel("Individual Text Type Controls:") + individual_label_font = QFont('Arial', 9) + individual_label_font.setBold(True) + self.individual_controls_header_label.setFont(individual_label_font) + iterations_layout.addWidget(self.individual_controls_header_label) + + # Text Bubble iterations (modified from original bubble iterations) + text_bubble_iter_widget = QWidget() + text_bubble_iter_layout = QHBoxLayout(text_bubble_iter_widget) + text_bubble_iter_layout.setContentsMargins(0, 0, 0, 0) + iterations_layout.addWidget(text_bubble_iter_widget) + + self.text_bubble_label = QLabel("Text Bubbles:") + self.text_bubble_label.setMinimumWidth(120) + text_bubble_iter_layout.addWidget(self.text_bubble_label) + + self.text_bubble_iter_spinbox = QSpinBox() + self.text_bubble_iter_spinbox.setRange(0, 5) + self.text_bubble_iter_spinbox.setValue(self.settings.get('text_bubble_dilation_iterations', + self.settings.get('bubble_dilation_iterations', 2))) + text_bubble_iter_layout.addWidget(self.text_bubble_iter_spinbox) + + self.text_bubble_desc = QLabel("iterations (speech/dialogue bubbles)") + text_bubble_iter_layout.addWidget(self.text_bubble_desc) + text_bubble_iter_layout.addStretch() + + # Empty Bubble iterations (NEW) + empty_bubble_iter_widget = QWidget() + empty_bubble_iter_layout = QHBoxLayout(empty_bubble_iter_widget) + empty_bubble_iter_layout.setContentsMargins(0, 0, 0, 0) + iterations_layout.addWidget(empty_bubble_iter_widget) + + self.empty_bubble_label = QLabel("Empty Bubbles:") + self.empty_bubble_label.setMinimumWidth(120) + empty_bubble_iter_layout.addWidget(self.empty_bubble_label) + + self.empty_bubble_iter_spinbox = QSpinBox() + self.empty_bubble_iter_spinbox.setRange(0, 5) + self.empty_bubble_iter_spinbox.setValue(self.settings.get('empty_bubble_dilation_iterations', 3)) + empty_bubble_iter_layout.addWidget(self.empty_bubble_iter_spinbox) + + self.empty_bubble_desc = QLabel("iterations (empty speech bubbles)") + empty_bubble_iter_layout.addWidget(self.empty_bubble_desc) + empty_bubble_iter_layout.addStretch() + + # Free text iterations + free_text_iter_widget = QWidget() + free_text_iter_layout = QHBoxLayout(free_text_iter_widget) + free_text_iter_layout.setContentsMargins(0, 0, 0, 0) + iterations_layout.addWidget(free_text_iter_widget) + + self.free_text_label = QLabel("Free Text:") + self.free_text_label.setMinimumWidth(120) + free_text_iter_layout.addWidget(self.free_text_label) + + self.free_text_iter_spinbox = QSpinBox() + self.free_text_iter_spinbox.setRange(0, 5) + self.free_text_iter_spinbox.setValue(self.settings.get('free_text_dilation_iterations', 0)) + free_text_iter_layout.addWidget(self.free_text_iter_spinbox) + + self.free_text_desc = QLabel("iterations (0 = perfect for B&W panels)") + free_text_iter_layout.addWidget(self.free_text_desc) + free_text_iter_layout.addStretch() + + # Store individual control widgets for enable/disable (includes descriptive labels) + self.individual_iteration_controls = [ + (self.text_bubble_label, self.text_bubble_iter_spinbox, self.text_bubble_desc), + (self.empty_bubble_label, self.empty_bubble_iter_spinbox, self.empty_bubble_desc), + (self.free_text_label, self.free_text_iter_spinbox, self.free_text_desc) + ] + + # Apply initial state + self._toggle_iteration_controls() + + # Quick presets - UPDATED VERSION + preset_widget = QWidget() + preset_layout = QHBoxLayout(preset_widget) + preset_layout.setContentsMargins(0, 0, 0, 0) + mask_dilation_layout.addWidget(preset_widget) + + preset_label = QLabel("Quick Presets:") + preset_layout.addWidget(preset_label) + preset_layout.addSpacing(10) + + bw_manga_btn = QPushButton("B&W Manga") + bw_manga_btn.setStyleSheet(""" + QPushButton { + background-color: #3a7ca5; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #4a8cb5; + } + QPushButton:pressed { + background-color: #2a6c95; + } + """) + bw_manga_btn.clicked.connect(lambda: self._set_mask_preset(15, False, 2, 2, 3, 0)) + preset_layout.addWidget(bw_manga_btn) + + colored_btn = QPushButton("Colored") + colored_btn.setStyleSheet(""" + QPushButton { + background-color: #3a7ca5; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #4a8cb5; + } + QPushButton:pressed { + background-color: #2a6c95; + } + """) + colored_btn.clicked.connect(lambda: self._set_mask_preset(15, False, 2, 2, 3, 3)) + preset_layout.addWidget(colored_btn) + + uniform_btn = QPushButton("Uniform") + uniform_btn.setStyleSheet(""" + QPushButton { + background-color: #3a7ca5; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #4a8cb5; + } + QPushButton:pressed { + background-color: #2a6c95; + } + """) + uniform_btn.clicked.connect(lambda: self._set_mask_preset(0, True, 2, 2, 2, 0)) + preset_layout.addWidget(uniform_btn) + + preset_layout.addStretch() + + # Help text - UPDATED + help_text = QLabel( + "💡 B&W Manga: Optimized for black & white panels with clean bubbles\n" + "💡 Colored: For colored manga with complex backgrounds\n" + "💡 Aggressive: For difficult text removal cases\n" + "💡 Uniform: Good for Manga-OCR\n" + "ℹ️ Empty bubbles often need more iterations than text bubbles\n" + "ℹ️ Set Free Text to 0 for crisp B&W panels without bleeding" + ) + help_text_font = QFont('Arial', 9) + help_text.setFont(help_text_font) + help_text.setStyleSheet("color: gray;") + help_text.setWordWrap(True) + mask_dilation_layout.addWidget(help_text) + + main_layout.addStretch() + + def _toggle_iteration_controls(self): + """Enable/disable iteration controls based on Auto and 'Use Same For All' toggles""" + # Get auto checkbox state + auto_on = False + if hasattr(self, 'auto_iterations_checkbox'): + auto_on = self.auto_iterations_checkbox.isChecked() + + # Get use_all checkbox state + use_all = False + if hasattr(self, 'use_all_iterations_checkbox'): + use_all = self.use_all_iterations_checkbox.isChecked() + + # Also update the auto_iterations_enabled attribute + self.auto_iterations_enabled = auto_on + + if auto_on: + # Disable ALL mask dilation and iteration controls when auto is on + # Mask dilation controls + try: + if hasattr(self, 'mask_dilation_spinbox'): + self.mask_dilation_spinbox.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'dilation_label'): + self.dilation_label.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'dilation_unit_label'): + self.dilation_unit_label.setEnabled(False) + except Exception: + pass + # Kernel size controls + try: + if hasattr(self, 'kernel_size_spinbox'): + self.kernel_size_spinbox.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'kernel_size_label'): + self.kernel_size_label.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'kernel_size_unit_label'): + self.kernel_size_unit_label.setEnabled(False) + except Exception: + pass + # Iteration controls + try: + self.all_iterations_spinbox.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'all_iter_label'): + self.all_iter_label.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'use_all_iterations_checkbox'): + self.use_all_iterations_checkbox.setEnabled(False) + except Exception: + pass + try: + if hasattr(self, 'individual_controls_header_label'): + self.individual_controls_header_label.setEnabled(False) + except Exception: + pass + # Disable individual controls and their description labels + for control_tuple in getattr(self, 'individual_iteration_controls', []): + try: + if len(control_tuple) == 3: + label, spinbox, desc_label = control_tuple + spinbox.setEnabled(False) + label.setEnabled(False) + desc_label.setEnabled(False) + elif len(control_tuple) == 2: + label, spinbox = control_tuple + spinbox.setEnabled(False) + label.setEnabled(False) + except Exception: + pass + return + + # Auto off -> enable mask dilation (always) and "Use Same For All" (respect its state) + # Mask dilation is always enabled when auto is off + try: + if hasattr(self, 'mask_dilation_spinbox'): + self.mask_dilation_spinbox.setEnabled(True) + except Exception: + pass + try: + if hasattr(self, 'dilation_label'): + self.dilation_label.setEnabled(True) + except Exception: + pass + try: + if hasattr(self, 'dilation_unit_label'): + self.dilation_unit_label.setEnabled(True) + except Exception: + pass + # Kernel size is always enabled when auto is off + try: + if hasattr(self, 'kernel_size_spinbox'): + self.kernel_size_spinbox.setEnabled(True) + except Exception: + pass + try: + if hasattr(self, 'kernel_size_label'): + self.kernel_size_label.setEnabled(True) + except Exception: + pass + try: + if hasattr(self, 'kernel_size_unit_label'): + self.kernel_size_unit_label.setEnabled(True) + except Exception: + pass + + try: + if hasattr(self, 'use_all_iterations_checkbox'): + self.use_all_iterations_checkbox.setEnabled(True) + except Exception: + pass + + try: + self.all_iterations_spinbox.setEnabled(use_all) + except Exception: + pass + try: + if hasattr(self, 'all_iter_label'): + self.all_iter_label.setEnabled(use_all) + except Exception: + pass + try: + if hasattr(self, 'individual_controls_header_label'): + self.individual_controls_header_label.setEnabled(not use_all) + except Exception: + pass + + # Individual controls respect the "Use Same For All" state + for control_tuple in getattr(self, 'individual_iteration_controls', []): + enabled = not use_all + try: + if len(control_tuple) == 3: + label, spinbox, desc_label = control_tuple + spinbox.setEnabled(enabled) + label.setEnabled(enabled) + desc_label.setEnabled(enabled) + elif len(control_tuple) == 2: + label, spinbox = control_tuple + spinbox.setEnabled(enabled) + label.setEnabled(enabled) + except Exception: + pass + + def _on_primary_auto_toggle(self, checked): + """When primary Auto toggle changes, disable/enable 'Use Same For All' checkbox""" + if hasattr(self, 'use_all_iterations_checkbox'): + self.use_all_iterations_checkbox.setEnabled(not checked) + + def _set_mask_preset(self, dilation, use_all, all_iter, text_bubble_iter, empty_bubble_iter, free_text_iter): + """Set mask dilation preset values with comprehensive iteration controls""" + self.mask_dilation_spinbox.setValue(dilation) + self.use_all_iterations_checkbox.setChecked(use_all) + self.all_iterations_spinbox.setValue(all_iter) + self.text_bubble_iter_spinbox.setValue(text_bubble_iter) + self.empty_bubble_iter_spinbox.setValue(empty_bubble_iter) + self.free_text_iter_spinbox.setValue(free_text_iter) + self._toggle_iteration_controls() + + def _create_cloud_api_tab(self): + """Create cloud API settings tab""" + # Create tab widget and add to tab widget + tab_widget = QWidget() + self.tab_widget.addTab(tab_widget, "Cloud API") + + # Create scroll area for content + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + content_widget = QWidget() + content_layout = QVBoxLayout(content_widget) + content_layout.setSpacing(10) + content_layout.setContentsMargins(20, 20, 20, 20) + + scroll_area.setWidget(content_widget) + + # Add scroll area to parent layout + parent_layout = QVBoxLayout(tab_widget) + parent_layout.setContentsMargins(0, 0, 0, 0) + parent_layout.addWidget(scroll_area) + + # API Model Selection + model_group = QGroupBox("Inpainting Model") + model_layout = QVBoxLayout(model_group) + content_layout.addWidget(model_group) + + model_desc = QLabel("Select the Replicate model to use for inpainting:") + model_layout.addWidget(model_desc) + model_layout.addSpacing(10) + + # Model options - use button group for radio buttons + self.cloud_model_button_group = QButtonGroup() + self.cloud_model_selected = self.settings.get('cloud_inpaint_model', 'ideogram-v2') + + models = [ + ('ideogram-v2', 'Ideogram V2 (Best quality, with prompts)', 'ideogram-ai/ideogram-v2'), + ('sd-inpainting', 'Stable Diffusion Inpainting (Classic, fast)', 'stability-ai/stable-diffusion-inpainting'), + ('flux-inpainting', 'FLUX Dev Inpainting (High quality)', 'zsxkib/flux-dev-inpainting'), + ('custom', 'Custom Model (Enter model identifier)', '') + ] + + for value, text, model_id in models: + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + model_layout.addWidget(row_widget) + + rb = QRadioButton(text) + rb.setChecked(value == self.cloud_model_selected) + rb.toggled.connect(lambda checked, v=value: self._on_cloud_model_change(v) if checked else None) + self.cloud_model_button_group.addButton(rb) + row_layout.addWidget(rb) + + if model_id: + model_id_label = QLabel(f"({model_id})") + model_id_font = QFont('Arial', 8) + model_id_label.setFont(model_id_font) + model_id_label.setStyleSheet("color: gray;") + row_layout.addWidget(model_id_label) + + row_layout.addStretch() + + # Custom version ID (now model identifier) + self.custom_version_widget = QWidget() + custom_version_layout = QVBoxLayout(self.custom_version_widget) + custom_version_layout.setContentsMargins(0, 10, 0, 0) + model_layout.addWidget(self.custom_version_widget) + + custom_id_row = QWidget() + custom_id_layout = QHBoxLayout(custom_id_row) + custom_id_layout.setContentsMargins(0, 0, 0, 0) + custom_version_layout.addWidget(custom_id_row) + + custom_id_label = QLabel("Model ID:") + custom_id_label.setMinimumWidth(120) + custom_id_layout.addWidget(custom_id_label) + + self.custom_version_entry = QLineEdit() + self.custom_version_entry.setText(self.settings.get('cloud_custom_version', '')) + custom_id_layout.addWidget(self.custom_version_entry) + + # Add helper text for custom model + helper_text = QLabel("Format: owner/model-name (e.g. stability-ai/stable-diffusion-inpainting)") + helper_font = QFont('Arial', 8) + helper_text.setFont(helper_font) + helper_text.setStyleSheet("color: gray;") + helper_text.setContentsMargins(120, 0, 0, 0) + custom_version_layout.addWidget(helper_text) + + # Initially hide custom version entry + if self.cloud_model_selected != 'custom': + self.custom_version_widget.setVisible(False) + + # Performance Settings + perf_group = QGroupBox("Performance Settings") + perf_layout = QVBoxLayout(perf_group) + content_layout.addWidget(perf_group) + + # Timeout + timeout_widget = QWidget() + timeout_layout = QHBoxLayout(timeout_widget) + timeout_layout.setContentsMargins(0, 0, 0, 0) + perf_layout.addWidget(timeout_widget) + + timeout_label = QLabel("API Timeout:") + timeout_label.setMinimumWidth(120) + timeout_layout.addWidget(timeout_label) + + self.cloud_timeout_spinbox = QSpinBox() + self.cloud_timeout_spinbox.setRange(30, 300) + self.cloud_timeout_spinbox.setValue(self.settings.get('cloud_timeout', 60)) + timeout_layout.addWidget(self.cloud_timeout_spinbox) + + timeout_unit = QLabel("seconds") + timeout_unit_font = QFont('Arial', 9) + timeout_unit.setFont(timeout_unit_font) + timeout_layout.addWidget(timeout_unit) + timeout_layout.addStretch() + + # Help text + help_text = QLabel( + "💡 Tips:\n" + "• Ideogram V2 is currently the best quality option\n" + "• SD inpainting is fast and supports prompts\n" + "• FLUX inpainting offers high quality results\n" + "• Find more models at replicate.com/collections/inpainting" + ) + help_font = QFont('Arial', 9) + help_text.setFont(help_font) + help_text.setStyleSheet("color: gray;") + help_text.setWordWrap(True) + content_layout.addWidget(help_text) + + # Prompt Settings (for all models except custom) + self.prompt_group = QGroupBox("Prompt Settings") + prompt_layout = QVBoxLayout(self.prompt_group) + content_layout.addWidget(self.prompt_group) + + # Positive prompt + prompt_label = QLabel("Inpainting Prompt:") + prompt_layout.addWidget(prompt_label) + + self.cloud_prompt_entry = QLineEdit() + self.cloud_prompt_entry.setText(self.settings.get('cloud_inpaint_prompt', 'clean background, smooth surface')) + prompt_layout.addWidget(self.cloud_prompt_entry) + + # Add note about prompts + prompt_tip = QLabel("Tip: Describe what you want in the inpainted area (e.g., 'white wall', 'wooden floor')") + prompt_tip_font = QFont('Arial', 8) + prompt_tip.setFont(prompt_tip_font) + prompt_tip.setStyleSheet("color: gray;") + prompt_tip.setWordWrap(True) + prompt_tip.setContentsMargins(0, 2, 0, 10) + prompt_layout.addWidget(prompt_tip) + + # Negative prompt (mainly for SD) + self.negative_prompt_label = QLabel("Negative Prompt (SD only):") + prompt_layout.addWidget(self.negative_prompt_label) + + self.negative_entry = QLineEdit() + self.negative_entry.setText(self.settings.get('cloud_negative_prompt', 'text, writing, letters')) + prompt_layout.addWidget(self.negative_entry) + + # Inference steps (for SD) + self.steps_widget = QWidget() + steps_layout = QHBoxLayout(self.steps_widget) + steps_layout.setContentsMargins(0, 10, 0, 5) + prompt_layout.addWidget(self.steps_widget) + + self.steps_label = QLabel("Inference Steps (SD only):") + self.steps_label.setMinimumWidth(180) + steps_layout.addWidget(self.steps_label) + + self.steps_spinbox = QSpinBox() + self.steps_spinbox.setRange(10, 50) + self.steps_spinbox.setValue(self.settings.get('cloud_inference_steps', 20)) + steps_layout.addWidget(self.steps_spinbox) + + steps_desc = QLabel("(Higher = better quality, slower)") + steps_desc_font = QFont('Arial', 9) + steps_desc.setFont(steps_desc_font) + steps_desc.setStyleSheet("color: gray;") + steps_layout.addWidget(steps_desc) + steps_layout.addStretch() + + # Add stretch at end + content_layout.addStretch() + + # Initially hide prompt frame if not using appropriate model + if self.cloud_model_selected == 'custom': + self.prompt_group.setVisible(False) + + # Show/hide SD-specific options based on model + self._on_cloud_model_change(self.cloud_model_selected) + + def _on_cloud_model_change(self, model): + """Handle cloud model selection change""" + # Store the selected model + self.cloud_model_selected = model + + # Show/hide custom version entry + if model == 'custom': + self.custom_version_widget.setVisible(True) + # DON'T HIDE THE PROMPT FRAME FOR CUSTOM MODELS + self.prompt_group.setVisible(True) + else: + self.custom_version_widget.setVisible(False) + self.prompt_group.setVisible(True) + + # Show/hide SD-specific options + if model == 'sd-inpainting': + # Show negative prompt and steps + self.negative_prompt_label.setVisible(True) + self.negative_entry.setVisible(True) + self.steps_widget.setVisible(True) + else: + # Hide SD-specific options + self.negative_prompt_label.setVisible(False) + self.negative_entry.setVisible(False) + self.steps_widget.setVisible(False) + + def _toggle_preprocessing(self): + """Enable/disable preprocessing controls based on main toggle""" + enabled = self.preprocess_enabled.isChecked() + + # Process each control in preprocessing_controls list + for control in self.preprocessing_controls: + try: + if isinstance(control, QGroupBox): + # Enable/disable entire group box children + self._toggle_frame_children(control, enabled) + elif isinstance(control, (QSlider, QSpinBox, QCheckBox, QDoubleSpinBox, QComboBox, QLabel)): + # Just use setEnabled() - the global stylesheet handles the visual state + control.setEnabled(enabled) + except Exception as e: + pass + + # Ensure tiling fields respect their own toggle regardless of preprocessing state + try: + if hasattr(self, '_toggle_tiling_controls'): + self._toggle_tiling_controls() + except Exception: + pass + + def _toggle_frame_children(self, widget, enabled): + """Recursively enable/disable all children of a widget""" + # Handle all controls including labels - just use setEnabled() + for child in widget.findChildren(QWidget): + if isinstance(child, (QSlider, QSpinBox, QCheckBox, QDoubleSpinBox, QComboBox, QLineEdit, QLabel)): + try: + child.setEnabled(enabled) + except Exception: + pass + + def _toggle_roi_locality_controls(self): + """Show/hide ROI locality controls based on toggle.""" + try: + enabled = self.roi_locality_checkbox.isChecked() + except Exception: + enabled = False + # Rows to manage + rows = [ + getattr(self, 'roi_pad_row', None), + getattr(self, 'roi_min_row', None), + getattr(self, 'roi_area_row', None), + getattr(self, 'roi_max_row', None) + ] + for row in rows: + try: + if row is None: continue + row.setVisible(enabled) + except Exception: + pass + + def _toggle_tiling_controls(self): + """Enable/disable tiling size/overlap fields based on tiling toggle.""" + try: + enabled = bool(self.inpaint_tiling_enabled.isChecked()) + except Exception: + enabled = False + + # Enable/disable tiling widgets and their labels + widgets_to_toggle = [ + ('tile_size_spinbox', 'tile_size_label', 'tile_size_unit_label'), + ('tile_overlap_spinbox', 'tile_overlap_label', 'tile_overlap_unit_label') + ] + + for widget_names in widgets_to_toggle: + for widget_name in widget_names: + try: + widget = getattr(self, widget_name, None) + if widget is not None: + # Just use setEnabled() for everything - stylesheet handles visuals + widget.setEnabled(enabled) + except Exception: + pass + + def _on_hd_strategy_change(self): + """Show/hide HD strategy controls based on selected strategy.""" + try: + strategy = self.hd_strategy_combo.currentText() + except Exception: + strategy = 'original' + + # Show/hide resize limit based on strategy + if hasattr(self, 'hd_resize_frame'): + self.hd_resize_frame.setVisible(strategy == 'resize') + + # Show/hide crop params based on strategy + if hasattr(self, 'hd_crop_margin_frame'): + self.hd_crop_margin_frame.setVisible(strategy == 'crop') + if hasattr(self, 'hd_crop_trigger_frame'): + self.hd_crop_trigger_frame.setVisible(strategy == 'crop') + + def _toggle_compression_enabled(self): + """Enable/disable compression controls based on compression toggle.""" + try: + enabled = bool(self.compression_enabled.isChecked()) + except Exception: + enabled = False + + # Enable/disable all compression format controls + compression_widgets = [ + getattr(self, 'format_label', None), + getattr(self, 'compression_format_combo', None), + getattr(self, 'jpeg_frame', None), + getattr(self, 'jpeg_label', None), + getattr(self, 'jpeg_quality_spin', None), + getattr(self, 'jpeg_help', None), + getattr(self, 'png_frame', None), + getattr(self, 'png_label', None), + getattr(self, 'png_level_spin', None), + getattr(self, 'png_help', None), + getattr(self, 'webp_frame', None), + getattr(self, 'webp_label', None), + getattr(self, 'webp_quality_spin', None), + getattr(self, 'webp_help', None), + ] + + for widget in compression_widgets: + try: + if widget is not None: + widget.setEnabled(enabled) + except Exception: + pass + + def _toggle_compression_format(self): + """Show only the controls relevant to the selected format (hide others).""" + fmt = self.compression_format_combo.currentText().lower() if hasattr(self, 'compression_format_combo') else 'jpeg' + try: + # Hide all rows first + for row in [getattr(self, 'jpeg_frame', None), getattr(self, 'png_frame', None), getattr(self, 'webp_frame', None)]: + try: + if row is not None: + row.setVisible(False) + except Exception: + pass + # Show the selected one + if fmt == 'jpeg': + if hasattr(self, 'jpeg_frame') and self.jpeg_frame is not None: + self.jpeg_frame.setVisible(True) + elif fmt == 'png': + if hasattr(self, 'png_frame') and self.png_frame is not None: + self.png_frame.setVisible(True) + else: # webp + if hasattr(self, 'webp_frame') and self.webp_frame is not None: + self.webp_frame.setVisible(True) + except Exception: + pass + + def _toggle_ocr_batching_controls(self): + """Show/hide OCR batching rows based on enable toggle.""" + try: + enabled = bool(self.ocr_batch_enabled_checkbox.isChecked()) + except Exception: + enabled = False + try: + if hasattr(self, 'ocr_bs_row') and self.ocr_bs_row: + self.ocr_bs_row.setVisible(enabled) + except Exception: + pass + try: + if hasattr(self, 'ocr_cc_row') and self.ocr_cc_row: + self.ocr_cc_row.setVisible(enabled) + except Exception: + pass + + def _create_ocr_tab(self): + """Create OCR settings tab with all options""" + # Create tab widget and add to tab widget + tab_widget = QWidget() + self.tab_widget.addTab(tab_widget, "OCR") + + # Create scroll area for OCR settings + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + content_widget = QWidget() + content_layout = QVBoxLayout(content_widget) + content_layout.setSpacing(10) + content_layout.setContentsMargins(20, 20, 20, 20) + + scroll_area.setWidget(content_widget) + + # Add scroll area to parent layout + parent_layout = QVBoxLayout(tab_widget) + parent_layout.setContentsMargins(0, 0, 0, 0) + parent_layout.addWidget(scroll_area) + + # Language hints + lang_group = QGroupBox("Language Detection") + lang_layout = QVBoxLayout(lang_group) + content_layout.addWidget(lang_group) + + lang_desc = QLabel("Select languages to prioritize during OCR:") + lang_desc_font = QFont('Arial', 10) + lang_desc.setFont(lang_desc_font) + lang_layout.addWidget(lang_desc) + lang_layout.addSpacing(10) + + # Language checkboxes + self.lang_checkboxes = {} + languages = [ + ('ja', 'Japanese'), + ('ko', 'Korean'), + ('zh', 'Chinese (Simplified)'), + ('zh-TW', 'Chinese (Traditional)'), + ('en', 'English') + ] + + lang_grid_widget = QWidget() + lang_grid_layout = QGridLayout(lang_grid_widget) + lang_layout.addWidget(lang_grid_widget) + + for i, (code, name) in enumerate(languages): + checkbox = self._create_styled_checkbox(name) + checkbox.setChecked(code in self.settings['ocr']['language_hints']) + self.lang_checkboxes[code] = checkbox + lang_grid_layout.addWidget(checkbox, i//2, i%2) + + # OCR parameters + ocr_group = QGroupBox("OCR Parameters") + ocr_layout = QVBoxLayout(ocr_group) + content_layout.addWidget(ocr_group) + + # Cloud OCR Confidence threshold (for Google/Azure only) + conf_widget = QWidget() + conf_layout = QHBoxLayout(conf_widget) + conf_layout.setContentsMargins(0, 0, 0, 0) + ocr_layout.addWidget(conf_widget) + + conf_label = QLabel("☁️ Cloud OCR Confidence:") + conf_label.setMinimumWidth(180) + conf_label.setToolTip("Applies to Google Cloud Vision and Azure OCR only.\nLocal OCR (RapidOCR, PaddleOCR, etc.) uses RT-DETR confidence only (comic-translate approach).") + conf_layout.addWidget(conf_label) + + # Get cloud OCR confidence (fallback to old setting for migration) + cloud_conf = self.settings['ocr'].get('cloud_ocr_confidence', self.settings['ocr'].get('confidence_threshold', 0.0)) + + self.confidence_threshold_slider = QSlider(Qt.Orientation.Horizontal) + self.confidence_threshold_slider.setRange(0, 100) + self.confidence_threshold_slider.setValue(int(cloud_conf * 100)) + self.confidence_threshold_slider.setMinimumWidth(250) + self.confidence_threshold_slider.setToolTip("0 = accept all (recommended, like comic-translate)\nHigher values filter low-confidence cloud OCR results") + conf_layout.addWidget(self.confidence_threshold_slider) + + self.confidence_threshold_label = QLabel(f"{cloud_conf:.2f}") + self.confidence_threshold_label.setMinimumWidth(50) + self.confidence_threshold_slider.valueChanged.connect( + lambda v: self.confidence_threshold_label.setText(f"{v/100:.2f}") + ) + conf_layout.addWidget(self.confidence_threshold_label) + conf_layout.addStretch() + + # Add info label below slider + conf_info = QLabel("ℹ️ Local OCR providers (RapidOCR, PaddleOCR, EasyOCR, DocTR) don't use this - they rely on RT-DETR confidence only") + conf_info_font = QFont('Arial', 9) + conf_info.setFont(conf_info_font) + conf_info.setStyleSheet("color: gray; font-style: italic; padding-left: 10px;") + conf_info.setWordWrap(True) + ocr_layout.addWidget(conf_info) + + # Detection mode + mode_widget = QWidget() + mode_layout = QHBoxLayout(mode_widget) + mode_layout.setContentsMargins(0, 0, 0, 0) + ocr_layout.addWidget(mode_widget) + + mode_label = QLabel("Detection Mode:") + mode_label.setMinimumWidth(180) + mode_layout.addWidget(mode_label) + + self.detection_mode_combo = QComboBox() + self.detection_mode_combo.addItems(['document', 'text']) + self.detection_mode_combo.setCurrentText(self.settings['ocr']['text_detection_mode']) + mode_layout.addWidget(self.detection_mode_combo) + + mode_desc = QLabel("(document = better for manga, text = simple layouts)") + mode_desc_font = QFont('Arial', 9) + mode_desc.setFont(mode_desc_font) + mode_desc.setStyleSheet("color: gray;") + mode_layout.addWidget(mode_desc) + mode_layout.addStretch() + + # Minimum region size for cloud OCR (Google/Azure) + min_region_widget = QWidget() + min_region_layout = QHBoxLayout(min_region_widget) + min_region_layout.setContentsMargins(0, 0, 0, 0) + ocr_layout.addWidget(min_region_widget) + + min_region_label = QLabel("☁️ Min Region Size:") + min_region_label.setMinimumWidth(180) + min_region_label.setToolTip( + "Minimum dimension for cloud OCR regions (Google/Azure).\n" + "Regions smaller than this will be resized before OCR.\n\n" + "• 50px = Default (safer, ensures good OCR quality)\n" + "• 32px = Smaller minimum (like some implementations)\n" + "• 0px = Disabled (send regions as-is, may fail on very small text)" + ) + min_region_layout.addWidget(min_region_label) + + self.min_region_size_spinbox = QSpinBox() + self.min_region_size_spinbox.setRange(0, 100) + self.min_region_size_spinbox.setSingleStep(1) + self.min_region_size_spinbox.setValue(self.settings['ocr'].get('min_region_size', 50)) + self.min_region_size_spinbox.setToolTip("0 = disabled, 32-50 = recommended range") + min_region_layout.addWidget(self.min_region_size_spinbox) + + min_region_unit = QLabel("pixels") + min_region_layout.addWidget(min_region_unit) + + min_region_desc = QLabel("(0 = no resize, comic-translate style)") + min_region_desc_font = QFont('Arial', 9) + min_region_desc.setFont(min_region_desc_font) + min_region_desc.setStyleSheet("color: gray;") + min_region_layout.addWidget(min_region_desc) + min_region_layout.addStretch() + + # Text merging settings + merge_group = QGroupBox("Text Region Merging") + merge_layout = QVBoxLayout(merge_group) + content_layout.addWidget(merge_group) + + # Merge nearby threshold + nearby_widget = QWidget() + nearby_layout = QHBoxLayout(nearby_widget) + nearby_layout.setContentsMargins(0, 0, 0, 0) + merge_layout.addWidget(nearby_widget) + + nearby_label = QLabel("Merge Distance:") + nearby_label.setMinimumWidth(180) + nearby_layout.addWidget(nearby_label) + + self.merge_nearby_threshold_spinbox = QSpinBox() + self.merge_nearby_threshold_spinbox.setRange(0, 200) + self.merge_nearby_threshold_spinbox.setSingleStep(10) + self.merge_nearby_threshold_spinbox.setValue(self.settings['ocr']['merge_nearby_threshold']) + nearby_layout.addWidget(self.merge_nearby_threshold_spinbox) + + nearby_unit = QLabel("pixels") + nearby_layout.addWidget(nearby_unit) + nearby_layout.addStretch() + + # Text Filtering Setting + filter_group = QGroupBox("Text Filtering") + filter_layout = QVBoxLayout(filter_group) + content_layout.addWidget(filter_group) + + # Minimum text length + min_length_widget = QWidget() + min_length_layout = QHBoxLayout(min_length_widget) + min_length_layout.setContentsMargins(0, 0, 0, 0) + filter_layout.addWidget(min_length_widget) + + min_length_label = QLabel("Min Text Length:") + min_length_label.setMinimumWidth(180) + min_length_layout.addWidget(min_length_label) + + self.min_text_length_spinbox = QSpinBox() + self.min_text_length_spinbox.setRange(1, 10) + self.min_text_length_spinbox.setValue(self.settings['ocr'].get('min_text_length', 0)) + min_length_layout.addWidget(self.min_text_length_spinbox) + + min_length_unit = QLabel("characters") + min_length_layout.addWidget(min_length_unit) + + min_length_desc = QLabel("(skip text shorter than this)") + min_length_desc_font = QFont('Arial', 9) + min_length_desc.setFont(min_length_desc_font) + min_length_desc.setStyleSheet("color: gray;") + min_length_layout.addWidget(min_length_desc) + min_length_layout.addStretch() + + # Exclude English text checkbox + self.exclude_english_checkbox = self._create_styled_checkbox("Exclude primarily English text (tunable threshold)") + self.exclude_english_checkbox.setChecked(self.settings['ocr'].get('exclude_english_text', False)) + filter_layout.addWidget(self.exclude_english_checkbox) + + # Threshold slider + english_threshold_widget = QWidget() + english_threshold_layout = QHBoxLayout(english_threshold_widget) + english_threshold_layout.setContentsMargins(0, 0, 0, 0) + filter_layout.addWidget(english_threshold_widget) + + threshold_label = QLabel("English Exclude Threshold:") + threshold_label.setMinimumWidth(240) + english_threshold_layout.addWidget(threshold_label) + + self.english_exclude_threshold_slider = QSlider(Qt.Orientation.Horizontal) + self.english_exclude_threshold_slider.setRange(60, 99) + self.english_exclude_threshold_slider.setValue(int(self.settings['ocr'].get('english_exclude_threshold', 0.7) * 100)) + self.english_exclude_threshold_slider.setMinimumWidth(250) + english_threshold_layout.addWidget(self.english_exclude_threshold_slider) + + self.english_threshold_label = QLabel(f"{int(self.settings['ocr'].get('english_exclude_threshold', 0.7)*100)}%") + self.english_threshold_label.setMinimumWidth(50) + self.english_exclude_threshold_slider.valueChanged.connect( + lambda v: self.english_threshold_label.setText(f"{v}%") + ) + english_threshold_layout.addWidget(self.english_threshold_label) + english_threshold_layout.addStretch() + + # Minimum character count + min_chars_widget = QWidget() + min_chars_layout = QHBoxLayout(min_chars_widget) + min_chars_layout.setContentsMargins(0, 0, 0, 0) + filter_layout.addWidget(min_chars_widget) + + min_chars_label = QLabel("Min chars to exclude as English:") + min_chars_label.setMinimumWidth(240) + min_chars_layout.addWidget(min_chars_label) + + self.english_exclude_min_chars_spinbox = QSpinBox() + self.english_exclude_min_chars_spinbox.setRange(1, 10) + self.english_exclude_min_chars_spinbox.setValue(self.settings['ocr'].get('english_exclude_min_chars', 4)) + min_chars_layout.addWidget(self.english_exclude_min_chars_spinbox) + + min_chars_unit = QLabel("characters") + min_chars_layout.addWidget(min_chars_unit) + min_chars_layout.addStretch() + + # Legacy aggressive short-token filter + self.english_exclude_short_tokens_checkbox = self._create_styled_checkbox("Aggressively drop very short ASCII tokens (legacy)") + self.english_exclude_short_tokens_checkbox.setChecked(self.settings['ocr'].get('english_exclude_short_tokens', False)) + filter_layout.addWidget(self.english_exclude_short_tokens_checkbox) + + # Help text + filter_help = QLabel( + "💡 Text filtering helps skip:\n" + " • UI elements and watermarks\n" + " • Page numbers and copyright text\n" + " • Single characters or symbols\n" + " • Non-target language text" + ) + filter_help_font = QFont('Arial', 9) + filter_help.setFont(filter_help_font) + filter_help.setStyleSheet("color: gray;") + filter_help.setWordWrap(True) + filter_help.setContentsMargins(0, 10, 0, 0) + filter_layout.addWidget(filter_help) + + # Azure-specific OCR settings (simplified - new API is synchronous) + azure_ocr_group = QGroupBox("Azure OCR Settings") + azure_ocr_layout = QVBoxLayout(azure_ocr_group) + content_layout.addWidget(azure_ocr_group) + + # Azure merge multiplier (kept for backward compatibility) + merge_mult_widget = QWidget() + merge_mult_layout = QHBoxLayout(merge_mult_widget) + merge_mult_layout.setContentsMargins(0, 0, 0, 0) + azure_ocr_layout.addWidget(merge_mult_widget) + + merge_mult_label = QLabel("Merge Multiplier:") + merge_mult_label.setMinimumWidth(180) + merge_mult_layout.addWidget(merge_mult_label) + + self.azure_merge_multiplier_slider = QSlider(Qt.Orientation.Horizontal) + self.azure_merge_multiplier_slider.setRange(100, 500) + self.azure_merge_multiplier_slider.setValue(int(self.settings['ocr'].get('azure_merge_multiplier', 2.0) * 100)) + self.azure_merge_multiplier_slider.setMinimumWidth(200) + merge_mult_layout.addWidget(self.azure_merge_multiplier_slider) + + self.azure_label = QLabel(f"{self.settings['ocr'].get('azure_merge_multiplier', 2.0):.2f}x") + self.azure_label.setMinimumWidth(50) + self.azure_merge_multiplier_slider.valueChanged.connect( + lambda v: self.azure_label.setText(f"{v/100:.2f}x") + ) + merge_mult_layout.addWidget(self.azure_label) + + merge_mult_desc = QLabel("(multiplies merge distance for Azure lines)") + merge_mult_desc_font = QFont('Arial', 9) + merge_mult_desc.setFont(merge_mult_desc_font) + merge_mult_desc.setStyleSheet("color: gray;") + merge_mult_layout.addWidget(merge_mult_desc) + merge_mult_layout.addStretch() + + # Help text + azure_help = QLabel( + "💡 Azure uses new Image Analysis API (synchronous, no polling)\n" + "💡 Language auto-detection works well for manga" + ) + azure_help_font = QFont('Arial', 9) + azure_help.setFont(azure_help_font) + azure_help.setStyleSheet("color: gray;") + azure_help.setWordWrap(True) + azure_help.setContentsMargins(0, 10, 0, 0) + azure_ocr_layout.addWidget(azure_help) + + # Rotation correction + self.enable_rotation_checkbox = self._create_styled_checkbox("Enable automatic rotation correction for tilted text") + self.enable_rotation_checkbox.setChecked(self.settings['ocr']['enable_rotation_correction']) + merge_layout.addWidget(self.enable_rotation_checkbox) + + # OCR batching and locality settings + ocr_batch_group = QGroupBox("OCR Batching & Concurrency") + ocr_batch_layout = QVBoxLayout(ocr_batch_group) + content_layout.addWidget(ocr_batch_group) + + # Enable OCR batching + self.ocr_batch_enabled_checkbox = self._create_styled_checkbox("Enable OCR batching (independent of translation batching)") + self.ocr_batch_enabled_checkbox.setChecked(self.settings['ocr'].get('ocr_batch_enabled', True)) + self.ocr_batch_enabled_checkbox.stateChanged.connect(self._toggle_ocr_batching_controls) + ocr_batch_layout.addWidget(self.ocr_batch_enabled_checkbox) + + # OCR batch size + ocr_bs_widget = QWidget() + ocr_bs_layout = QHBoxLayout(ocr_bs_widget) + ocr_bs_layout.setContentsMargins(0, 0, 0, 0) + ocr_batch_layout.addWidget(ocr_bs_widget) + self.ocr_bs_row = ocr_bs_widget + + ocr_bs_label = QLabel("OCR Batch Size:") + ocr_bs_label.setMinimumWidth(180) + ocr_bs_layout.addWidget(ocr_bs_label) + + self.ocr_batch_size_spinbox = QSpinBox() + self.ocr_batch_size_spinbox.setRange(1, 32) + self.ocr_batch_size_spinbox.setValue(int(self.settings['ocr'].get('ocr_batch_size', 8))) + ocr_bs_layout.addWidget(self.ocr_batch_size_spinbox) + + ocr_bs_desc = QLabel("(Google: items/request; Azure: drives concurrency)") + ocr_bs_desc_font = QFont('Arial', 9) + ocr_bs_desc.setFont(ocr_bs_desc_font) + ocr_bs_desc.setStyleSheet("color: gray;") + ocr_bs_layout.addWidget(ocr_bs_desc) + ocr_bs_layout.addStretch() + + # OCR Max Concurrency + ocr_cc_widget = QWidget() + ocr_cc_layout = QHBoxLayout(ocr_cc_widget) + ocr_cc_layout.setContentsMargins(0, 0, 0, 0) + ocr_batch_layout.addWidget(ocr_cc_widget) + self.ocr_cc_row = ocr_cc_widget + + ocr_cc_label = QLabel("OCR Max Concurrency:") + ocr_cc_label.setMinimumWidth(180) + ocr_cc_layout.addWidget(ocr_cc_label) + + self.ocr_max_conc_spinbox = QSpinBox() + self.ocr_max_conc_spinbox.setRange(1, 8) + self.ocr_max_conc_spinbox.setValue(int(self.settings['ocr'].get('ocr_max_concurrency', 2))) + ocr_cc_layout.addWidget(self.ocr_max_conc_spinbox) + + ocr_cc_desc = QLabel("(Google: concurrent requests; Azure: workers, capped at 4)") + ocr_cc_desc_font = QFont('Arial', 9) + ocr_cc_desc.setFont(ocr_cc_desc_font) + ocr_cc_desc.setStyleSheet("color: gray;") + ocr_cc_layout.addWidget(ocr_cc_desc) + ocr_cc_layout.addStretch() + + # Apply initial visibility for OCR batching controls + try: + self._toggle_ocr_batching_controls() + except Exception: + pass + + # ROI sizing + roi_group = QGroupBox("ROI Locality Controls") + roi_layout = QVBoxLayout(roi_group) + content_layout.addWidget(roi_group) + + # ROI locality toggle (now inside this section) + self.roi_locality_checkbox = self._create_styled_checkbox("Enable ROI-based OCR locality and batching (uses bubble detection)") + self.roi_locality_checkbox.setChecked(self.settings['ocr'].get('roi_locality_enabled', False)) + self.roi_locality_checkbox.stateChanged.connect(self._toggle_roi_locality_controls) + roi_layout.addWidget(self.roi_locality_checkbox) + + # ROI padding ratio + roi_pad_widget = QWidget() + roi_pad_layout = QHBoxLayout(roi_pad_widget) + roi_pad_layout.setContentsMargins(0, 0, 0, 0) + roi_layout.addWidget(roi_pad_widget) + self.roi_pad_row = roi_pad_widget + + roi_pad_label = QLabel("ROI Padding Ratio:") + roi_pad_label.setMinimumWidth(180) + roi_pad_layout.addWidget(roi_pad_label) + + self.roi_padding_ratio_slider = QSlider(Qt.Orientation.Horizontal) + self.roi_padding_ratio_slider.setRange(0, 30) + self.roi_padding_ratio_slider.setValue(int(float(self.settings['ocr'].get('roi_padding_ratio', 0.08)) * 100)) + self.roi_padding_ratio_slider.setMinimumWidth(200) + roi_pad_layout.addWidget(self.roi_padding_ratio_slider) + + self.roi_padding_ratio_label = QLabel(f"{float(self.settings['ocr'].get('roi_padding_ratio', 0.08)):.2f}") + self.roi_padding_ratio_label.setMinimumWidth(50) + self.roi_padding_ratio_slider.valueChanged.connect( + lambda v: self.roi_padding_ratio_label.setText(f"{v/100:.2f}") + ) + roi_pad_layout.addWidget(self.roi_padding_ratio_label) + roi_pad_layout.addStretch() + + # ROI min side / area + roi_min_widget = QWidget() + roi_min_layout = QHBoxLayout(roi_min_widget) + roi_min_layout.setContentsMargins(0, 0, 0, 0) + roi_layout.addWidget(roi_min_widget) + self.roi_min_row = roi_min_widget + + roi_min_label = QLabel("Min ROI Side:") + roi_min_label.setMinimumWidth(180) + roi_min_layout.addWidget(roi_min_label) + + self.roi_min_side_spinbox = QSpinBox() + self.roi_min_side_spinbox.setRange(1, 64) + self.roi_min_side_spinbox.setValue(int(self.settings['ocr'].get('roi_min_side_px', 12))) + roi_min_layout.addWidget(self.roi_min_side_spinbox) + + roi_min_unit = QLabel("px") + roi_min_layout.addWidget(roi_min_unit) + roi_min_layout.addStretch() + + roi_area_widget = QWidget() + roi_area_layout = QHBoxLayout(roi_area_widget) + roi_area_layout.setContentsMargins(0, 0, 0, 0) + roi_layout.addWidget(roi_area_widget) + self.roi_area_row = roi_area_widget + + roi_area_label = QLabel("Min ROI Area:") + roi_area_label.setMinimumWidth(180) + roi_area_layout.addWidget(roi_area_label) + + self.roi_min_area_spinbox = QSpinBox() + self.roi_min_area_spinbox.setRange(1, 5000) + self.roi_min_area_spinbox.setValue(int(self.settings['ocr'].get('roi_min_area_px', 100))) + roi_area_layout.addWidget(self.roi_min_area_spinbox) + + roi_area_unit = QLabel("px^2") + roi_area_layout.addWidget(roi_area_unit) + roi_area_layout.addStretch() + + # ROI max side (0 disables) + roi_max_widget = QWidget() + roi_max_layout = QHBoxLayout(roi_max_widget) + roi_max_layout.setContentsMargins(0, 0, 0, 0) + roi_layout.addWidget(roi_max_widget) + self.roi_max_row = roi_max_widget + + roi_max_label = QLabel("ROI Max Side (0=off):") + roi_max_label.setMinimumWidth(180) + roi_max_layout.addWidget(roi_max_label) + + self.roi_max_side_spinbox = QSpinBox() + self.roi_max_side_spinbox.setRange(0, 2048) + self.roi_max_side_spinbox.setValue(int(self.settings['ocr'].get('roi_max_side', 0))) + roi_max_layout.addWidget(self.roi_max_side_spinbox) + roi_max_layout.addStretch() + + # Apply initial visibility based on toggle + self._toggle_roi_locality_controls() + + # AI Bubble Detection Settings + bubble_group = QGroupBox("AI Bubble Detection") + bubble_layout = QVBoxLayout(bubble_group) + content_layout.addWidget(bubble_group) + + # Enable bubble detection + self.bubble_detection_enabled_checkbox = self._create_styled_checkbox("Enable AI-powered bubble detection (overrides traditional merging)") + # IMPORTANT: Default to True for optimal text detection (especially for Chinese/Japanese text) + self.bubble_detection_enabled_checkbox.setChecked(self.settings['ocr'].get('bubble_detection_enabled', True)) + self.bubble_detection_enabled_checkbox.stateChanged.connect(self._toggle_bubble_controls) + bubble_layout.addWidget(self.bubble_detection_enabled_checkbox) + + # Use RT-DETR for text region detection (not just bubble detection) + self.use_rtdetr_for_ocr_checkbox = self._create_styled_checkbox("Use RT-DETR to guide OCR (Google/Azure only - others already do this)") + self.use_rtdetr_for_ocr_checkbox.setChecked(self.settings['ocr'].get('use_rtdetr_for_ocr_regions', True)) # Default: True for best accuracy + self.use_rtdetr_for_ocr_checkbox.setToolTip( + "When enabled, RT-DETR first detects all text regions (text bubbles + free text), \n" + "then your OCR provider reads each region separately.\n\n" + "🎯 Applies to: Google Cloud Vision, Azure Computer Vision\n" + "✓ Already enabled: Qwen2-VL, Custom API, EasyOCR, PaddleOCR, DocTR, manga-ocr\n\n" + "Benefits:\n" + "• More accurate text detection (trained specifically for manga/comics)\n" + "• Better separation of overlapping text\n" + "• Improved handling of different text types (bubbles vs. free text)\n" + "• Focused OCR on actual text regions (faster, more accurate)\n\n" + "Note: Requires bubble detection to be enabled and uses the selected detector above." + ) + bubble_layout.addWidget(self.use_rtdetr_for_ocr_checkbox) + + # Detector type dropdown + detector_type_widget = QWidget() + detector_type_layout = QHBoxLayout(detector_type_widget) + detector_type_layout.setContentsMargins(0, 10, 0, 0) + bubble_layout.addWidget(detector_type_widget) + + detector_type_label = QLabel("Detector:") + detector_type_label.setMinimumWidth(120) + detector_type_layout.addWidget(detector_type_label) + + # Model mapping + self.detector_models = { + 'RTEDR_onnx': 'ogkalu/comic-text-and-bubble-detector', + 'RT-DETR': 'ogkalu/comic-text-and-bubble-detector', + 'YOLOv8 Speech': 'ogkalu/comic-speech-bubble-detector-yolov8m', + 'YOLOv8 Text': 'ogkalu/comic-text-segmenter-yolov8m', + 'YOLOv8 Manga': 'ogkalu/manga-text-detector-yolov8s', + 'Custom Model': '' + } + + # Get saved detector type (default to ONNX backend) + saved_type = self.settings['ocr'].get('detector_type', 'rtdetr_onnx') + if saved_type == 'rtdetr_onnx': + initial_selection = 'RTEDR_onnx' + elif saved_type == 'rtdetr': + initial_selection = 'RT-DETR' + elif saved_type == 'yolo': + initial_selection = 'YOLOv8 Speech' + elif saved_type == 'custom': + initial_selection = 'Custom Model' + else: + initial_selection = 'RTEDR_onnx' + + self.detector_type_combo = QComboBox() + self.detector_type_combo.addItems(list(self.detector_models.keys())) + self.detector_type_combo.setCurrentText(initial_selection) + self.detector_type_combo.currentTextChanged.connect(self._on_detector_type_changed) + detector_type_layout.addWidget(self.detector_type_combo) + detector_type_layout.addStretch() + + # NOW create the settings frame + self.yolo_settings_group = QGroupBox("Model Settings") + yolo_settings_layout = QVBoxLayout(self.yolo_settings_group) + bubble_layout.addWidget(self.yolo_settings_group) + self.rtdetr_settings_frame = self.yolo_settings_group # Alias for compatibility + + # Model path/URL row + model_widget = QWidget() + model_layout = QHBoxLayout(model_widget) + model_layout.setContentsMargins(0, 5, 0, 0) + yolo_settings_layout.addWidget(model_widget) + + model_label = QLabel("Model:") + model_label.setMinimumWidth(100) + model_layout.addWidget(model_label) + + self.bubble_model_entry = QLineEdit() + self.bubble_model_entry.setText(self.settings['ocr'].get('bubble_model_path', '')) + self.bubble_model_entry.setReadOnly(True) + self.bubble_model_entry.setStyleSheet( + "QLineEdit { background-color: #1e1e1e; color: #ffffff; border: 1px solid #3a3a3a; }" + ) + model_layout.addWidget(self.bubble_model_entry) + self.rtdetr_url_entry = self.bubble_model_entry # Alias + + # Store for compatibility + self.detector_radio_widgets = [self.detector_type_combo] + + # Browse and Clear buttons (initially hidden for HuggingFace models) + self.bubble_browse_btn = QPushButton("Browse") + self.bubble_browse_btn.clicked.connect(self._browse_bubble_model) + model_layout.addWidget(self.bubble_browse_btn) + + self.bubble_clear_btn = QPushButton("Clear") + self.bubble_clear_btn.clicked.connect(self._clear_bubble_model) + model_layout.addWidget(self.bubble_clear_btn) + model_layout.addStretch() + + # Download and Load buttons + button_widget = QWidget() + button_layout = QHBoxLayout(button_widget) + button_layout.setContentsMargins(0, 10, 0, 0) + yolo_settings_layout.addWidget(button_widget) + + button_label = QLabel("Actions:") + button_label.setMinimumWidth(100) + button_layout.addWidget(button_label) + + self.rtdetr_download_btn = QPushButton("Download") + self.rtdetr_download_btn.clicked.connect(self._download_rtdetr_model) + self.rtdetr_download_btn.setStyleSheet(""" + QPushButton { + background-color: #5a9fd4; + color: white; + font-weight: bold; + border: none; + border-radius: 3px; + padding: 5px 15px; + } + QPushButton:hover { + background-color: #7bb3e0; + } + QPushButton:pressed { + background-color: #4a8fc4; + } + """) + button_layout.addWidget(self.rtdetr_download_btn) + + self.rtdetr_load_btn = QPushButton("Load Model") + self.rtdetr_load_btn.clicked.connect(self._load_rtdetr_model) + self.rtdetr_load_btn.setStyleSheet(""" + QPushButton { + background-color: #5a9fd4; + color: white; + font-weight: bold; + border: none; + border-radius: 3px; + padding: 5px 15px; + } + QPushButton:hover { + background-color: #7bb3e0; + } + QPushButton:pressed { + background-color: #4a8fc4; + } + """) + button_layout.addWidget(self.rtdetr_load_btn) + + self.rtdetr_status_label = QLabel("") + rtdetr_status_font = QFont('Arial', 9) + self.rtdetr_status_label.setFont(rtdetr_status_font) + button_layout.addWidget(self.rtdetr_status_label) + button_layout.addStretch() + + # RT-DETR Detection classes + rtdetr_classes_widget = QWidget() + rtdetr_classes_layout = QHBoxLayout(rtdetr_classes_widget) + rtdetr_classes_layout.setContentsMargins(0, 10, 0, 0) + yolo_settings_layout.addWidget(rtdetr_classes_widget) + self.rtdetr_classes_frame = rtdetr_classes_widget + + classes_label = QLabel("Detect:") + classes_label.setMinimumWidth(100) + rtdetr_classes_layout.addWidget(classes_label) + + self.detect_empty_bubbles_checkbox = self._create_styled_checkbox("Empty Bubbles") + self.detect_empty_bubbles_checkbox.setChecked(self.settings['ocr'].get('detect_empty_bubbles', True)) + rtdetr_classes_layout.addWidget(self.detect_empty_bubbles_checkbox) + + self.detect_text_bubbles_checkbox = self._create_styled_checkbox("Text Bubbles") + self.detect_text_bubbles_checkbox.setChecked(self.settings['ocr'].get('detect_text_bubbles', True)) + rtdetr_classes_layout.addWidget(self.detect_text_bubbles_checkbox) + + self.detect_free_text_checkbox = self._create_styled_checkbox("Free Text") + self.detect_free_text_checkbox.setChecked(self.settings['ocr'].get('detect_free_text', True)) + rtdetr_classes_layout.addWidget(self.detect_free_text_checkbox) + rtdetr_classes_layout.addStretch() + + # Confidence + conf_widget = QWidget() + conf_layout = QHBoxLayout(conf_widget) + conf_layout.setContentsMargins(0, 10, 0, 0) + yolo_settings_layout.addWidget(conf_widget) + + conf_label = QLabel("Confidence:") + conf_label.setMinimumWidth(100) + conf_layout.addWidget(conf_label) + + detector_label = self.detector_type_combo.currentText() + default_conf = 0.3 if ('RT-DETR' in detector_label or 'RTEDR_onnx' in detector_label or 'onnx' in detector_label.lower()) else 0.5 + + self.bubble_conf_slider = QSlider(Qt.Orientation.Horizontal) + self.bubble_conf_slider.setRange(0, 99) + self.bubble_conf_slider.setValue(int(self.settings['ocr'].get('bubble_confidence', default_conf) * 100)) + self.bubble_conf_slider.setMinimumWidth(200) + conf_layout.addWidget(self.bubble_conf_slider) + self.rtdetr_conf_scale = self.bubble_conf_slider # Alias + + self.bubble_conf_label = QLabel(f"{self.settings['ocr'].get('bubble_confidence', default_conf):.2f}") + self.bubble_conf_label.setMinimumWidth(50) + self.bubble_conf_slider.valueChanged.connect( + lambda v: self.bubble_conf_label.setText(f"{v/100:.2f}") + ) + conf_layout.addWidget(self.bubble_conf_label) + self.rtdetr_conf_label = self.bubble_conf_label # Alias + conf_layout.addStretch() + + # YOLO-specific: Max detections (only visible for YOLO) + self.yolo_maxdet_widget = QWidget() + yolo_maxdet_layout = QHBoxLayout(self.yolo_maxdet_widget) + yolo_maxdet_layout.setContentsMargins(0, 6, 0, 0) + yolo_settings_layout.addWidget(self.yolo_maxdet_widget) + self.yolo_maxdet_row = self.yolo_maxdet_widget # Alias + self.yolo_maxdet_widget.setVisible(False) # Hidden initially + + maxdet_label = QLabel("Max detections:") + maxdet_label.setMinimumWidth(100) + yolo_maxdet_layout.addWidget(maxdet_label) + + self.bubble_max_det_yolo_spinbox = QSpinBox() + self.bubble_max_det_yolo_spinbox.setRange(1, 2000) + self.bubble_max_det_yolo_spinbox.setValue(self.settings['ocr'].get('bubble_max_detections_yolo', 100)) + yolo_maxdet_layout.addWidget(self.bubble_max_det_yolo_spinbox) + yolo_maxdet_layout.addStretch() + + # Status label at the bottom of bubble group + self.bubble_status_label = QLabel("") + bubble_status_font = QFont('Arial', 9) + self.bubble_status_label.setFont(bubble_status_font) + bubble_status_label_container = QWidget() + bubble_status_label_layout = QVBoxLayout(bubble_status_label_container) + bubble_status_label_layout.setContentsMargins(0, 10, 0, 0) + bubble_status_label_layout.addWidget(self.bubble_status_label) + bubble_layout.addWidget(bubble_status_label_container) + + # Store controls for enable/disable + self.bubble_controls = [ + self.detector_type_combo, + self.bubble_model_entry, + self.bubble_browse_btn, + self.bubble_clear_btn, + self.bubble_conf_slider, + self.rtdetr_download_btn, + self.rtdetr_load_btn + ] + + self.rtdetr_controls = [ + self.bubble_model_entry, + self.rtdetr_load_btn, + self.rtdetr_download_btn, + self.bubble_conf_slider, + self.detect_empty_bubbles_checkbox, + self.detect_text_bubbles_checkbox, + self.detect_free_text_checkbox + ] + + self.yolo_controls = [ + self.bubble_model_entry, + self.bubble_browse_btn, + self.bubble_clear_btn, + self.bubble_conf_slider, + self.yolo_maxdet_widget + ] + + # Add stretch to end of OCR tab content + content_layout.addStretch() + + # Initialize control states + self._toggle_bubble_controls() + + # Only call detector change after everything is initialized + if self.bubble_detection_enabled_checkbox.isChecked(): + try: + self._on_detector_type_changed() + self._update_bubble_status() + except AttributeError: + # Frames not yet created, skip initialization + pass + + # Check status after dialog ready + QTimer.singleShot(500, self._check_rtdetr_status) + + def _on_detector_type_changed(self, detector=None): + """Handle detector type change""" + if not hasattr(self, 'bubble_detection_enabled_checkbox'): + return + + if not self.bubble_detection_enabled_checkbox.isChecked(): + self.yolo_settings_group.setVisible(False) + return + + if detector is None: + detector = self.detector_type_combo.currentText() + + # Handle different detector types + if detector == 'Custom Model': + # Custom model - enable manual entry + self.bubble_model_entry.setText(self.settings['ocr'].get('custom_model_path', '')) + self.bubble_model_entry.setReadOnly(False) + self.bubble_model_entry.setStyleSheet( + "QLineEdit { background-color: #2b2b2b; color: #ffffff; border: 1px solid #3a3a3a; }" + ) + # Show browse/clear buttons for custom + self.bubble_browse_btn.setVisible(True) + self.bubble_clear_btn.setVisible(True) + # Hide download button + self.rtdetr_download_btn.setVisible(False) + elif detector in self.detector_models: + # HuggingFace model + url = self.detector_models[detector] + self.bubble_model_entry.setText(url) + # Make entry read-only for HuggingFace models + self.bubble_model_entry.setReadOnly(True) + self.bubble_model_entry.setStyleSheet( + "QLineEdit { background-color: #1e1e1e; color: #ffffff; border: 1px solid #3a3a3a; }" + ) + # Hide browse/clear buttons for HuggingFace models + self.bubble_browse_btn.setVisible(False) + self.bubble_clear_btn.setVisible(False) + # Show download button + self.rtdetr_download_btn.setVisible(True) + + # Show/hide RT-DETR specific controls + is_rtdetr = 'RT-DETR' in detector or 'RTEDR_onnx' in detector + + if is_rtdetr: + self.rtdetr_classes_frame.setVisible(True) + # Hide YOLO-only max det row + self.yolo_maxdet_widget.setVisible(False) + else: + self.rtdetr_classes_frame.setVisible(False) + # Show YOLO-only max det row for YOLO models + if 'YOLO' in detector or 'Yolo' in detector or 'yolo' in detector or detector == 'Custom Model': + self.yolo_maxdet_widget.setVisible(True) + else: + self.yolo_maxdet_widget.setVisible(False) + + # Show/hide RT-DETR concurrency control in Performance section (Advanced tab) + # Only update if the widget has been created (Advanced tab may not be loaded yet) + if hasattr(self, 'rtdetr_conc_frame'): + self.rtdetr_conc_frame.setVisible(is_rtdetr) + + # Always show settings frame + self.yolo_settings_group.setVisible(True) + + # Update status + self._update_bubble_status() + + def _download_rtdetr_model(self): + """Download selected model""" + try: + detector = self.detector_type_combo.currentText() + model_url = self.bubble_model_entry.text() + + self.rtdetr_status_label.setText("Downloading...") + self.rtdetr_status_label.setStyleSheet("color: orange;") + QApplication.processEvents() + + if 'RTEDR_onnx' in detector: + from bubble_detector import BubbleDetector + bd = BubbleDetector() + if bd.load_rtdetr_onnx_model(model_id=model_url): + self.rtdetr_status_label.setText("✅ Downloaded") + self.rtdetr_status_label.setStyleSheet("color: green;") + QMessageBox.information(self, "Success", f"RTEDR_onnx model downloaded successfully!") + else: + self.rtdetr_status_label.setText("❌ Failed") + self.rtdetr_status_label.setStyleSheet("color: red;") + QMessageBox.critical(self, "Error", f"Failed to download RTEDR_onnx model") + elif 'RT-DETR' in detector: + # RT-DETR handling (works fine) + from bubble_detector import BubbleDetector + bd = BubbleDetector() + + if bd.load_rtdetr_model(model_id=model_url): + self.rtdetr_status_label.setText("✅ Downloaded") + self.rtdetr_status_label.setStyleSheet("color: green;") + QMessageBox.information(self, "Success", f"RT-DETR model downloaded successfully!") + else: + self.rtdetr_status_label.setText("❌ Failed") + self.rtdetr_status_label.setStyleSheet("color: red;") + QMessageBox.critical(self, "Error", f"Failed to download RT-DETR model") + else: + # FIX FOR YOLO: Download to a simpler local path + from huggingface_hub import hf_hub_download + import os + + # Create models directory + models_dir = "models" + os.makedirs(models_dir, exist_ok=True) + + # Define simple local filenames + filename_map = { + 'ogkalu/comic-speech-bubble-detector-yolov8m': 'comic-speech-bubble-detector.pt', + 'ogkalu/comic-text-segmenter-yolov8m': 'comic-text-segmenter.pt', + 'ogkalu/manga-text-detector-yolov8s': 'manga-text-detector.pt' + } + + filename = filename_map.get(model_url, 'model.pt') + + # Download to cache first + cached_path = hf_hub_download(repo_id=model_url, filename=filename) + + # Copy to local models directory with simple path + import shutil + local_path = os.path.join(models_dir, filename) + shutil.copy2(cached_path, local_path) + + # Set the simple local path instead of the cache path + self.bubble_model_entry.setText(local_path) + self.rtdetr_status_label.setText("✅ Downloaded") + self.rtdetr_status_label.setStyleSheet("color: green;") + QMessageBox.information(self, "Success", f"Model downloaded to:\n{local_path}") + + except ImportError: + self.rtdetr_status_label.setText("❌ Missing deps") + self.rtdetr_status_label.setStyleSheet("color: red;") + QMessageBox.critical(self, "Error", "Install: pip install huggingface-hub transformers") + except Exception as e: + self.rtdetr_status_label.setText("❌ Error") + self.rtdetr_status_label.setStyleSheet("color: red;") + QMessageBox.critical(self, "Error", f"Download failed: {e}") + + def _check_rtdetr_status(self): + """Check if model is already loaded""" + try: + from bubble_detector import BubbleDetector + + if hasattr(self.main_gui, 'manga_tab') and hasattr(self.main_gui.manga_tab, 'translator'): + translator = self.main_gui.manga_tab.translator + if hasattr(translator, 'bubble_detector') and translator.bubble_detector: + if getattr(translator.bubble_detector, 'rtdetr_onnx_loaded', False): + self.rtdetr_status_label.setText("✅ Loaded") + self.rtdetr_status_label.setStyleSheet("color: green;") + return True + if getattr(translator.bubble_detector, 'rtdetr_loaded', False): + self.rtdetr_status_label.setText("✅ Loaded") + self.rtdetr_status_label.setStyleSheet("color: green;") + return True + elif getattr(translator.bubble_detector, 'model_loaded', False): + self.rtdetr_status_label.setText("✅ Loaded") + self.rtdetr_status_label.setStyleSheet("color: green;") + return True + + self.rtdetr_status_label.setText("Not loaded") + self.rtdetr_status_label.setStyleSheet("color: gray;") + return False + + except ImportError: + self.rtdetr_status_label.setText("❌ Missing deps") + self.rtdetr_status_label.setStyleSheet("color: red;") + return False + except Exception: + self.rtdetr_status_label.setText("Not loaded") + self.rtdetr_status_label.setStyleSheet("color: gray;") + return False + + def _load_rtdetr_model(self): + """Load selected model""" + try: + from bubble_detector import BubbleDetector + from PySide6.QtWidgets import QApplication + + self.rtdetr_status_label.setText("Loading...") + self.rtdetr_status_label.setStyleSheet("color: orange;") + QApplication.processEvents() + + bd = BubbleDetector() + detector = self.detector_type_combo.currentText() + model_path = self.bubble_model_entry.text() + + if 'RTEDR_onnx' in detector: + # RT-DETR (ONNX) uses repo id directly + if bd.load_rtdetr_onnx_model(model_id=model_path): + self.rtdetr_status_label.setText("✅ Ready") + self.rtdetr_status_label.setStyleSheet("color: green;") + QMessageBox.information(self, "Success", f"RTEDR_onnx model loaded successfully!") + else: + self.rtdetr_status_label.setText("❌ Failed") + self.rtdetr_status_label.setStyleSheet("color: red;") + elif 'RT-DETR' in detector: + # RT-DETR uses model_id directly + if bd.load_rtdetr_model(model_id=model_path): + self.rtdetr_status_label.setText("✅ Ready") + self.rtdetr_status_label.setStyleSheet("color: green;") + QMessageBox.information(self, "Success", f"RT-DETR model loaded successfully!") + else: + self.rtdetr_status_label.setText("❌ Failed") + self.rtdetr_status_label.setStyleSheet("color: red;") + else: + # YOLOv8 - CHECK LOCAL MODELS FOLDER FIRST + if model_path.startswith('ogkalu/'): + # It's a HuggingFace ID - check if already downloaded + filename_map = { + 'ogkalu/comic-speech-bubble-detector-yolov8m': 'comic-speech-bubble-detector.pt', + 'ogkalu/comic-text-segmenter-yolov8m': 'comic-text-segmenter.pt', + 'ogkalu/manga-text-detector-yolov8s': 'manga-text-detector.pt' + } + + filename = filename_map.get(model_path, 'model.pt') + local_path = os.path.join('models', filename) + + # Check if it exists locally + if os.path.exists(local_path): + # Use the local file + model_path = local_path + self.bubble_model_entry.setText(local_path) # Update the field + else: + # Not downloaded yet + QMessageBox.warning(self, "Download Required", + f"Model not found locally.\nPlease download it first using the Download button.") + self.rtdetr_status_label.setText("❌ Not downloaded") + self.rtdetr_status_label.setStyleSheet("color: orange;") + return + + # Now model_path should be a local file + if not os.path.exists(model_path): + QMessageBox.critical(self, "Error", f"Model file not found: {model_path}") + self.rtdetr_status_label.setText("❌ File not found") + self.rtdetr_status_label.setStyleSheet("color: red;") + return + + # Load the YOLOv8 model from local file + if bd.load_model(model_path): + self.rtdetr_status_label.setText("✅ Ready") + self.rtdetr_status_label.setStyleSheet("color: green;") + QMessageBox.information(self, "Success", f"YOLOv8 model loaded successfully!") + + # Auto-convert to ONNX if enabled + if os.environ.get('AUTO_CONVERT_TO_ONNX', 'true').lower() == 'true': + onnx_path = model_path.replace('.pt', '.onnx') + if not os.path.exists(onnx_path): + if bd.convert_to_onnx(model_path, onnx_path): + logger.info(f"✅ Converted to ONNX: {onnx_path}") + else: + self.rtdetr_status_label.setText("❌ Failed") + self.rtdetr_status_label.setStyleSheet("color: red;") + + except ImportError: + self.rtdetr_status_label.setText("❌ Missing deps") + self.rtdetr_status_label.setStyleSheet("color: red;") + QMessageBox.critical(self, "Error", "Install transformers: pip install transformers") + except Exception as e: + self.rtdetr_status_label.setText("❌ Error") + self.rtdetr_status_label.setStyleSheet("color: red;") + QMessageBox.critical(self, "Error", f"Failed to load: {e}") + + def _toggle_bubble_controls(self): + """Enable/disable bubble detection controls""" + enabled = self.bubble_detection_enabled_checkbox.isChecked() + + if enabled: + # Enable controls + for widget in self.bubble_controls: + try: + widget.setEnabled(True) + except: + pass + + # Show/hide frames based on detector type + self._on_detector_type_changed() + else: + # Disable controls + for widget in self.bubble_controls: + try: + widget.setEnabled(False) + except: + pass + + # Hide frames + self.yolo_settings_group.setVisible(False) + self.bubble_status_label.setText("") + + def _browse_bubble_model(self): + """Browse for model file""" + path, _ = QFileDialog.getOpenFileName( + self, + "Select Model File", + "", + "Model files (*.pt *.pth *.bin *.safetensors);;All files (*.*)" + ) + + if path: + self.bubble_model_entry.setText(path) + self._update_bubble_status() + + def _clear_bubble_model(self): + """Clear selected model""" + self.bubble_model_entry.setText("") + self._update_bubble_status() + + def _update_bubble_status(self): + """Update bubble model status label""" + if not self.bubble_detection_enabled_checkbox.isChecked(): + self.bubble_status_label.setText("") + return + + detector = self.detector_type_combo.currentText() + model_path = self.bubble_model_entry.text() + + if not model_path: + self.bubble_status_label.setText("⚠️ No model selected") + self.bubble_status_label.setStyleSheet("color: orange;") + return + + if model_path.startswith("ogkalu/"): + self.bubble_status_label.setText(f"📥 {detector} ready to download") + self.bubble_status_label.setStyleSheet("color: blue;") + elif os.path.exists(model_path): + self.bubble_status_label.setText("✅ Model file ready") + self.bubble_status_label.setStyleSheet("color: green;") + else: + self.bubble_status_label.setText("❌ Model file not found") + self.bubble_status_label.setStyleSheet("color: red;") + + def _update_azure_label(self): + """Update Azure multiplier label""" + # This method is deprecated - Azure multiplier UI was removed + pass + + def _set_azure_multiplier(self, value): + """Set Azure multiplier from preset""" + # This method is deprecated - Azure multiplier UI was removed + pass + + def _create_advanced_tab(self): + """Create advanced settings tab with all options""" + # Create tab widget and add to tab widget + tab_widget = QWidget() + self.tab_widget.addTab(tab_widget, "Advanced") + + # Main scrollable content + main_layout = QVBoxLayout(tab_widget) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.setSpacing(6) + + # Format detection + detect_group = QGroupBox("Format Detection") + main_layout.addWidget(detect_group) + detect_layout = QVBoxLayout(detect_group) + detect_layout.setContentsMargins(8, 8, 8, 6) + detect_layout.setSpacing(4) + + self.format_detection_checkbox = self._create_styled_checkbox("Enable automatic manga format detection (reading direction)") + self.format_detection_checkbox.setChecked(self.settings['advanced']['format_detection']) + detect_layout.addWidget(self.format_detection_checkbox) + + # Webtoon mode + webtoon_frame = QWidget() + webtoon_layout = QHBoxLayout(webtoon_frame) + webtoon_layout.setContentsMargins(0, 0, 0, 0) + detect_layout.addWidget(webtoon_frame) + + webtoon_label = QLabel("Webtoon Mode:") + webtoon_label.setMinimumWidth(150) + webtoon_layout.addWidget(webtoon_label) + + self.webtoon_mode_combo = QComboBox() + self.webtoon_mode_combo.addItems(['auto', 'enabled', 'disabled']) + self.webtoon_mode_combo.setCurrentText(self.settings['advanced']['webtoon_mode']) + webtoon_layout.addWidget(self.webtoon_mode_combo) + webtoon_layout.addStretch() + + # Debug settings + debug_group = QGroupBox("Debug Options") + main_layout.addWidget(debug_group) + debug_layout = QVBoxLayout(debug_group) + debug_layout.setContentsMargins(8, 8, 8, 6) + debug_layout.setSpacing(4) + + self.debug_mode_checkbox = self._create_styled_checkbox("Enable debug mode (verbose logging)") + self.debug_mode_checkbox.setChecked(self.settings['advanced']['debug_mode']) + debug_layout.addWidget(self.debug_mode_checkbox) + + # New: Concise pipeline logs (reduce noise) + self.concise_logs_checkbox = self._create_styled_checkbox("Concise pipeline logs (reduce noise)") + self.concise_logs_checkbox.setChecked(bool(self.settings.get('advanced', {}).get('concise_logs', True))) + def _save_concise(): + try: + if 'advanced' not in self.settings: + self.settings['advanced'] = {} + self.settings['advanced']['concise_logs'] = bool(self.concise_logs_checkbox.isChecked()) + if hasattr(self, 'config'): + self.config['manga_settings'] = self.settings + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + except Exception: + pass + self.concise_logs_checkbox.toggled.connect(_save_concise) + debug_layout.addWidget(self.concise_logs_checkbox) + + self.save_intermediate_checkbox = self._create_styled_checkbox("Save intermediate images (preprocessed, detection overlays)") + self.save_intermediate_checkbox.setChecked(self.settings['advanced']['save_intermediate']) + debug_layout.addWidget(self.save_intermediate_checkbox) + + # Performance settings + perf_group = QGroupBox("Performance") + main_layout.addWidget(perf_group) + perf_layout = QVBoxLayout(perf_group) + perf_layout.setContentsMargins(8, 8, 8, 6) + perf_layout.setSpacing(4) + + # New: Parallel rendering (per-region overlays) + self.render_parallel_checkbox = self._create_styled_checkbox("Enable parallel rendering (per-region overlays)") + self.render_parallel_checkbox.setChecked(self.settings.get('advanced', {}).get('render_parallel', True)) + perf_layout.addWidget(self.render_parallel_checkbox) + + self.parallel_processing_checkbox = self._create_styled_checkbox("Enable parallel processing (experimental)") + self.parallel_processing_checkbox.setChecked(self.settings['advanced']['parallel_processing']) + self.parallel_processing_checkbox.toggled.connect(self._toggle_workers) + perf_layout.addWidget(self.parallel_processing_checkbox) + + # Max workers + workers_frame = QWidget() + workers_layout = QHBoxLayout(workers_frame) + workers_layout.setContentsMargins(0, 0, 0, 0) + perf_layout.addWidget(workers_frame) + + self.workers_label = QLabel("Max Workers:") + self.workers_label.setMinimumWidth(150) + workers_layout.addWidget(self.workers_label) + + self.max_workers_spinbox = QSpinBox() + self.max_workers_spinbox.setRange(1, 999) + self.max_workers_spinbox.setValue(self.settings['advanced']['max_workers']) + workers_layout.addWidget(self.max_workers_spinbox) + + self.workers_desc_label = QLabel("(threads for parallel processing)") + workers_layout.addWidget(self.workers_desc_label) + workers_layout.addStretch() + + # Initialize workers state + self._toggle_workers() + + # Memory management section + memory_group = QGroupBox("Memory Management") + main_layout.addWidget(memory_group) + memory_layout = QVBoxLayout(memory_group) + memory_layout.setContentsMargins(8, 8, 8, 6) + memory_layout.setSpacing(4) + + # Singleton mode checkbox - will connect handler later after panel widgets created + self.use_singleton_models_checkbox = self._create_styled_checkbox("Use single model instances (saves RAM, only affects local models)") + self.use_singleton_models_checkbox.setChecked(self.settings.get('advanced', {}).get('use_singleton_models', True)) + self.use_singleton_models_checkbox.toggled.connect(self._toggle_singleton_controls) + memory_layout.addWidget(self.use_singleton_models_checkbox) + + # Singleton note + singleton_note = QLabel( + "When enabled: One bubble detector & one inpainter shared across all images.\n" + "When disabled: Each thread/image can have its own models (uses more RAM).\n" + "✅ Batch API translation remains fully functional with singleton mode enabled." + ) + singleton_note_font = QFont('Arial', 9) + singleton_note.setFont(singleton_note_font) + singleton_note.setStyleSheet("color: gray;") + singleton_note.setWordWrap(True) + memory_layout.addWidget(singleton_note) + + self.auto_cleanup_models_checkbox = self._create_styled_checkbox("Automatically cleanup models after translation to free RAM") + self.auto_cleanup_models_checkbox.setChecked(self.settings.get('advanced', {}).get('auto_cleanup_models', False)) + memory_layout.addWidget(self.auto_cleanup_models_checkbox) + + # Unload models after translation (disabled by default) + self.unload_models_checkbox = self._create_styled_checkbox("Unload models after translation (reset translator instance)") + self.unload_models_checkbox.setChecked(self.settings.get('advanced', {}).get('unload_models_after_translation', False)) + memory_layout.addWidget(self.unload_models_checkbox) + + # Add a note about parallel processing + note_label = QLabel("Note: When parallel panel translation is enabled, cleanup happens after ALL panels complete.") + note_font = QFont('Arial', 9) + note_label.setFont(note_font) + note_label.setStyleSheet("color: gray;") + note_label.setWordWrap(True) + memory_layout.addWidget(note_label) + + # Panel-level parallel translation + panel_group = QGroupBox("Parallel Panel Translation") + main_layout.addWidget(panel_group) + panel_layout = QVBoxLayout(panel_group) + panel_layout.setContentsMargins(8, 8, 8, 6) + panel_layout.setSpacing(4) + + # New: Preload local inpainting for panels (default ON) + self.preload_local_panels_checkbox = self._create_styled_checkbox("Preload local inpainting instances for panel-parallel runs") + self.preload_local_panels_checkbox.setChecked(self.settings.get('advanced', {}).get('preload_local_inpainting_for_panels', True)) + panel_layout.addWidget(self.preload_local_panels_checkbox) + + self.parallel_panel_checkbox = self._create_styled_checkbox("Enable parallel panel translation (process multiple images concurrently)") + self.parallel_panel_checkbox.setChecked(self.settings.get('advanced', {}).get('parallel_panel_translation', False)) + self.parallel_panel_checkbox.toggled.connect(self._toggle_panel_controls) + panel_layout.addWidget(self.parallel_panel_checkbox) + + # Local LLM Performance (add to performance group) + inpaint_perf_group = QGroupBox("Local LLM Performance") + perf_layout.addWidget(inpaint_perf_group) + inpaint_perf_layout = QVBoxLayout(inpaint_perf_group) + inpaint_perf_layout.setContentsMargins(8, 8, 8, 6) + inpaint_perf_layout.setSpacing(4) + + # RT-DETR Concurrency (for memory optimization) + rtdetr_conc_widget = QWidget() + rtdetr_conc_layout = QHBoxLayout(rtdetr_conc_widget) + rtdetr_conc_layout.setContentsMargins(0, 0, 0, 0) + inpaint_perf_layout.addWidget(rtdetr_conc_widget) + self.rtdetr_conc_frame = rtdetr_conc_widget + + rtdetr_conc_label = QLabel("RT-DETR Concurrency:") + rtdetr_conc_label.setMinimumWidth(150) + rtdetr_conc_layout.addWidget(rtdetr_conc_label) + + self.rtdetr_max_concurrency_spinbox = QSpinBox() + self.rtdetr_max_concurrency_spinbox.setRange(1, 999) + self.rtdetr_max_concurrency_spinbox.setValue(self.settings['ocr'].get('rtdetr_max_concurrency', 12)) + self.rtdetr_max_concurrency_spinbox.setToolTip("Maximum concurrent RT-DETR region OCR calls (rate limiting handled via delays)") + rtdetr_conc_layout.addWidget(self.rtdetr_max_concurrency_spinbox) + + rtdetr_conc_desc = QLabel("parallel OCR calls (lower = less RAM)") + rtdetr_conc_desc_font = QFont('Arial', 9) + rtdetr_conc_desc.setFont(rtdetr_conc_desc_font) + rtdetr_conc_desc.setStyleSheet("color: gray;") + rtdetr_conc_layout.addWidget(rtdetr_conc_desc) + rtdetr_conc_layout.addStretch() + + # Initially hide RT-DETR concurrency control until we check detector type + self.rtdetr_conc_frame.setVisible(False) + + # Inpainting Concurrency + inpaint_bs_frame = QWidget() + inpaint_bs_layout = QHBoxLayout(inpaint_bs_frame) + inpaint_bs_layout.setContentsMargins(0, 0, 0, 0) + inpaint_perf_layout.addWidget(inpaint_bs_frame) + + inpaint_bs_label = QLabel("Inpainting Concurrency:") + inpaint_bs_label.setMinimumWidth(150) + inpaint_bs_layout.addWidget(inpaint_bs_label) + + self.inpaint_batch_size_spinbox = QSpinBox() + self.inpaint_batch_size_spinbox.setRange(1, 32) + self.inpaint_batch_size_spinbox.setValue(self.settings.get('inpainting', {}).get('batch_size', 10)) + inpaint_bs_layout.addWidget(self.inpaint_batch_size_spinbox) + + inpaint_bs_help = QLabel("(process multiple regions at once)") + inpaint_bs_help_font = QFont('Arial', 9) + inpaint_bs_help.setFont(inpaint_bs_help_font) + inpaint_bs_help.setStyleSheet("color: gray;") + inpaint_bs_layout.addWidget(inpaint_bs_help) + inpaint_bs_layout.addStretch() + + self.enable_cache_checkbox = self._create_styled_checkbox("Enable inpainting cache (speeds up repeated processing)") + self.enable_cache_checkbox.setChecked(self.settings.get('inpainting', {}).get('enable_cache', True)) + inpaint_perf_layout.addWidget(self.enable_cache_checkbox) + + # Max concurrent panels + panels_frame = QWidget() + panels_layout = QHBoxLayout(panels_frame) + panels_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.addWidget(panels_frame) + + self.panels_label = QLabel("Max concurrent panels:") + self.panels_label.setMinimumWidth(150) + panels_layout.addWidget(self.panels_label) + + self.panel_max_workers_spinbox = QSpinBox() + self.panel_max_workers_spinbox.setRange(1, 999) + self.panel_max_workers_spinbox.setValue(self.settings.get('advanced', {}).get('panel_max_workers', 2)) + panels_layout.addWidget(self.panel_max_workers_spinbox) + panels_layout.addStretch() + + # Panel start stagger (ms) + stagger_frame = QWidget() + stagger_layout = QHBoxLayout(stagger_frame) + stagger_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.addWidget(stagger_frame) + + self.stagger_label = QLabel("Panel start stagger:") + self.stagger_label.setMinimumWidth(150) + stagger_layout.addWidget(self.stagger_label) + + self.panel_stagger_ms_spinbox = QSpinBox() + self.panel_stagger_ms_spinbox.setRange(0, 1000) + self.panel_stagger_ms_spinbox.setValue(self.settings.get('advanced', {}).get('panel_start_stagger_ms', 30)) + stagger_layout.addWidget(self.panel_stagger_ms_spinbox) + + self.stagger_unit_label = QLabel("ms") + stagger_layout.addWidget(self.stagger_unit_label) + stagger_layout.addStretch() + + # Initialize panel controls state + self._toggle_panel_controls() + self._toggle_singleton_controls() + + # ONNX conversion settings + onnx_group = QGroupBox("ONNX Conversion") + main_layout.addWidget(onnx_group) + onnx_layout = QVBoxLayout(onnx_group) + onnx_layout.setContentsMargins(8, 8, 8, 6) + onnx_layout.setSpacing(4) + + self.auto_convert_onnx_checkbox = self._create_styled_checkbox("Auto-convert local models to ONNX for faster inference (recommended)") + self.auto_convert_onnx_checkbox.setChecked(self.settings['advanced'].get('auto_convert_to_onnx', False)) + onnx_layout.addWidget(self.auto_convert_onnx_checkbox) + + self.auto_convert_onnx_bg_checkbox = self._create_styled_checkbox("Convert in background (non-blocking; switches to ONNX when ready)") + self.auto_convert_onnx_bg_checkbox.setChecked(self.settings['advanced'].get('auto_convert_to_onnx_background', True)) + onnx_layout.addWidget(self.auto_convert_onnx_bg_checkbox) + + # Connect toggle handler + def _toggle_onnx_controls(): + self.auto_convert_onnx_bg_checkbox.setEnabled(self.auto_convert_onnx_checkbox.isChecked()) + self.auto_convert_onnx_checkbox.toggled.connect(_toggle_onnx_controls) + _toggle_onnx_controls() + + # Model memory optimization (quantization) + quant_group = QGroupBox("Model Memory Optimization") + main_layout.addWidget(quant_group) + quant_layout = QVBoxLayout(quant_group) + quant_layout.setContentsMargins(8, 8, 8, 6) + quant_layout.setSpacing(4) + + self.quantize_models_checkbox = self._create_styled_checkbox("Reduce RAM with quantized models (global switch)") + self.quantize_models_checkbox.setChecked(self.settings['advanced'].get('quantize_models', False)) + quant_layout.addWidget(self.quantize_models_checkbox) + + # ONNX quantize sub-toggle + onnx_quant_frame = QWidget() + onnx_quant_layout = QHBoxLayout(onnx_quant_frame) + onnx_quant_layout.setContentsMargins(0, 0, 0, 0) + quant_layout.addWidget(onnx_quant_frame) + + self.onnx_quantize_checkbox = self._create_styled_checkbox("Quantize ONNX models to INT8 (dynamic)") + self.onnx_quantize_checkbox.setChecked(self.settings['advanced'].get('onnx_quantize', False)) + onnx_quant_layout.addWidget(self.onnx_quantize_checkbox) + + onnx_quant_help = QLabel("(lower RAM/CPU; slight accuracy trade-off)") + onnx_quant_help_font = QFont('Arial', 9) + onnx_quant_help.setFont(onnx_quant_help_font) + onnx_quant_help.setStyleSheet("color: gray;") + onnx_quant_layout.addWidget(onnx_quant_help) + onnx_quant_layout.addStretch() + + # Torch precision dropdown + precision_frame = QWidget() + precision_layout = QHBoxLayout(precision_frame) + precision_layout.setContentsMargins(0, 0, 0, 0) + quant_layout.addWidget(precision_frame) + + precision_label = QLabel("Torch precision:") + precision_label.setMinimumWidth(150) + precision_layout.addWidget(precision_label) + + self.torch_precision_combo = QComboBox() + self.torch_precision_combo.addItems(['fp16', 'fp32', 'auto']) + self.torch_precision_combo.setCurrentText(self.settings['advanced'].get('torch_precision', 'fp16')) + precision_layout.addWidget(self.torch_precision_combo) + + precision_help = QLabel("(fp16 only, since fp32 is currently bugged)") + precision_help_font = QFont('Arial', 9) + precision_help.setFont(precision_help_font) + precision_help.setStyleSheet("color: gray;") + precision_layout.addWidget(precision_help) + precision_layout.addStretch() + + # Aggressive memory cleanup + cleanup_group = QGroupBox("Memory & Cleanup") + main_layout.addWidget(cleanup_group) + cleanup_layout = QVBoxLayout(cleanup_group) + cleanup_layout.setContentsMargins(8, 8, 8, 6) + cleanup_layout.setSpacing(4) + + self.force_deep_cleanup_checkbox = self._create_styled_checkbox("Force deep model cleanup after every image (slowest, lowest RAM)") + self.force_deep_cleanup_checkbox.setChecked(self.settings.get('advanced', {}).get('force_deep_cleanup_each_image', False)) + cleanup_layout.addWidget(self.force_deep_cleanup_checkbox) + + cleanup_help = QLabel("Also clears shared caches at batch end.") + cleanup_help_font = QFont('Arial', 9) + cleanup_help.setFont(cleanup_help_font) + cleanup_help.setStyleSheet("color: gray;") + cleanup_layout.addWidget(cleanup_help) + + # RAM cap controls + self.ram_cap_enabled_checkbox = self._create_styled_checkbox("Enable RAM cap") + self.ram_cap_enabled_checkbox.setChecked(self.settings.get('advanced', {}).get('ram_cap_enabled', False)) + cleanup_layout.addWidget(self.ram_cap_enabled_checkbox) + + # RAM cap value + ramcap_value_frame = QWidget() + ramcap_value_layout = QHBoxLayout(ramcap_value_frame) + ramcap_value_layout.setContentsMargins(0, 0, 0, 0) + cleanup_layout.addWidget(ramcap_value_frame) + + ramcap_value_label = QLabel("Max RAM (MB):") + ramcap_value_label.setMinimumWidth(150) + ramcap_value_layout.addWidget(ramcap_value_label) + + self.ram_cap_mb_spinbox = QSpinBox() + self.ram_cap_mb_spinbox.setRange(512, 131072) + self.ram_cap_mb_spinbox.setValue(int(self.settings.get('advanced', {}).get('ram_cap_mb', 0) or 0)) + ramcap_value_layout.addWidget(self.ram_cap_mb_spinbox) + + ramcap_value_help = QLabel("(0 = disabled)") + ramcap_value_help_font = QFont('Arial', 9) + ramcap_value_help.setFont(ramcap_value_help_font) + ramcap_value_help.setStyleSheet("color: gray;") + ramcap_value_layout.addWidget(ramcap_value_help) + ramcap_value_layout.addStretch() + + # RAM cap mode + ramcap_mode_frame = QWidget() + ramcap_mode_layout = QHBoxLayout(ramcap_mode_frame) + ramcap_mode_layout.setContentsMargins(0, 0, 0, 0) + cleanup_layout.addWidget(ramcap_mode_frame) + + ramcap_mode_label = QLabel("Cap mode:") + ramcap_mode_label.setMinimumWidth(150) + ramcap_mode_layout.addWidget(ramcap_mode_label) + + self.ram_cap_mode_combo = QComboBox() + self.ram_cap_mode_combo.addItems(['soft', 'hard (Windows only)']) + self.ram_cap_mode_combo.setCurrentText(self.settings.get('advanced', {}).get('ram_cap_mode', 'soft')) + ramcap_mode_layout.addWidget(self.ram_cap_mode_combo) + + ramcap_mode_help = QLabel("Soft = clean/trim, Hard = OS-enforced (may OOM)") + ramcap_mode_help_font = QFont('Arial', 9) + ramcap_mode_help.setFont(ramcap_mode_help_font) + ramcap_mode_help.setStyleSheet("color: gray;") + ramcap_mode_layout.addWidget(ramcap_mode_help) + ramcap_mode_layout.addStretch() + + # Advanced RAM gate tuning + gate_frame = QWidget() + gate_layout = QHBoxLayout(gate_frame) + gate_layout.setContentsMargins(0, 0, 0, 0) + cleanup_layout.addWidget(gate_frame) + + gate_label = QLabel("Gate timeout (sec):") + gate_label.setMinimumWidth(150) + gate_layout.addWidget(gate_label) + + self.ram_gate_timeout_spinbox = QDoubleSpinBox() + self.ram_gate_timeout_spinbox.setRange(2.0, 60.0) + self.ram_gate_timeout_spinbox.setSingleStep(0.5) + self.ram_gate_timeout_spinbox.setValue(float(self.settings.get('advanced', {}).get('ram_gate_timeout_sec', 10.0))) + gate_layout.addWidget(self.ram_gate_timeout_spinbox) + gate_layout.addStretch() + + # Gate floor + floor_frame = QWidget() + floor_layout = QHBoxLayout(floor_frame) + floor_layout.setContentsMargins(0, 0, 0, 0) + cleanup_layout.addWidget(floor_frame) + + floor_label = QLabel("Gate floor over baseline (MB):") + floor_label.setMinimumWidth(180) + floor_layout.addWidget(floor_label) + + self.ram_gate_floor_spinbox = QSpinBox() + self.ram_gate_floor_spinbox.setRange(64, 2048) + self.ram_gate_floor_spinbox.setValue(int(self.settings.get('advanced', {}).get('ram_min_floor_over_baseline_mb', 128))) + floor_layout.addWidget(self.ram_gate_floor_spinbox) + floor_layout.addStretch() + + # Update RT-DETR concurrency control visibility based on current detector type + # This is called after the Advanced tab is fully created to sync with OCR tab state + QTimer.singleShot(0, self._sync_rtdetr_concurrency_visibility) + + def _sync_rtdetr_concurrency_visibility(self): + """Sync RT-DETR concurrency control visibility with detector type selection""" + if hasattr(self, 'detector_type_combo') and hasattr(self, 'rtdetr_conc_frame'): + detector = self.detector_type_combo.currentText() + is_rtdetr = 'RT-DETR' in detector or 'RTEDR_onnx' in detector + self.rtdetr_conc_frame.setVisible(is_rtdetr) + + def _toggle_workers(self): + """Enable/disable worker settings based on parallel processing toggle""" + if hasattr(self, 'parallel_processing_checkbox'): + enabled = bool(self.parallel_processing_checkbox.isChecked()) + if hasattr(self, 'max_workers_spinbox'): + self.max_workers_spinbox.setEnabled(enabled) + if hasattr(self, 'workers_label'): + self.workers_label.setEnabled(enabled) + self.workers_label.setStyleSheet("color: white;" if enabled else "color: gray;") + if hasattr(self, 'workers_desc_label'): + self.workers_desc_label.setEnabled(enabled) + self.workers_desc_label.setStyleSheet("color: white;" if enabled else "color: gray;") + + def _toggle_singleton_controls(self): + """Enable/disable parallel panel translation based on singleton toggle.""" + # When singleton mode is ENABLED, parallel panel translation should be DISABLED + try: + singleton_enabled = bool(self.use_singleton_models_checkbox.isChecked()) + except Exception: + singleton_enabled = True # Default + + # Disable parallel panel checkbox when singleton is enabled + if hasattr(self, 'parallel_panel_checkbox'): + self.parallel_panel_checkbox.setEnabled(not singleton_enabled) + if singleton_enabled: + # Also gray out the label when disabled + pass # The checkbox itself shows as disabled + + def _toggle_panel_controls(self): + """Enable/disable panel control fields based on parallel panel toggle.""" + try: + enabled = bool(self.parallel_panel_checkbox.isChecked()) + except Exception: + enabled = False + + # Enable/disable panel control widgets and their labels + panel_widgets = [ + ('panel_max_workers_spinbox', 'panels_label'), + ('panel_stagger_ms_spinbox', 'stagger_label', 'stagger_unit_label'), + ('preload_local_panels_checkbox',) # Add preload checkbox + ] + + for widget_names in panel_widgets: + for widget_name in widget_names: + try: + widget = getattr(self, widget_name, None) + if widget is not None: + # Just use setEnabled() - stylesheet handles visuals + widget.setEnabled(enabled) + except Exception: + pass + + def _apply_defaults_to_controls(self): + """Apply default values to all visible Tk variables/controls across tabs without rebuilding the dialog.""" + try: + # Use current in-memory settings (which we set to defaults above) + s = self.settings if isinstance(getattr(self, 'settings', None), dict) else self.default_settings + pre = s.get('preprocessing', {}) + comp = s.get('compression', {}) + ocr = s.get('ocr', {}) + adv = s.get('advanced', {}) + inp = s.get('inpainting', {}) + font = s.get('font_sizing', {}) + + # Preprocessing + if hasattr(self, 'preprocess_enabled'): self.preprocess_enabled.set(bool(pre.get('enabled', False))) + if hasattr(self, 'auto_detect'): self.auto_detect.set(bool(pre.get('auto_detect_quality', True))) + if hasattr(self, 'contrast_threshold'): self.contrast_threshold.set(float(pre.get('contrast_threshold', 0.4))) + if hasattr(self, 'sharpness_threshold'): self.sharpness_threshold.set(float(pre.get('sharpness_threshold', 0.3))) + if hasattr(self, 'enhancement_strength'): self.enhancement_strength.set(float(pre.get('enhancement_strength', 1.5))) + if hasattr(self, 'noise_threshold'): self.noise_threshold.set(int(pre.get('noise_threshold', 20))) + if hasattr(self, 'denoise_strength'): self.denoise_strength.set(int(pre.get('denoise_strength', 10))) + if hasattr(self, 'max_dimension'): self.max_dimension.set(int(pre.get('max_image_dimension', 2000))) + if hasattr(self, 'max_pixels'): self.max_pixels.set(int(pre.get('max_image_pixels', 2000000))) + if hasattr(self, 'chunk_height'): self.chunk_height.set(int(pre.get('chunk_height', 1000))) + if hasattr(self, 'chunk_overlap'): self.chunk_overlap.set(int(pre.get('chunk_overlap', 100))) + # Compression + if hasattr(self, 'compression_enabled_var'): self.compression_enabled_var.set(bool(comp.get('enabled', False))) + if hasattr(self, 'compression_format_var'): self.compression_format_var.set(str(comp.get('format', 'jpeg'))) + if hasattr(self, 'jpeg_quality_var'): self.jpeg_quality_var.set(int(comp.get('jpeg_quality', 85))) + if hasattr(self, 'png_level_var'): self.png_level_var.set(int(comp.get('png_compress_level', 6))) + if hasattr(self, 'webp_quality_var'): self.webp_quality_var.set(int(comp.get('webp_quality', 85))) + # Tiling + if hasattr(self, 'inpaint_tiling_enabled'): self.inpaint_tiling_enabled.set(bool(pre.get('inpaint_tiling_enabled', False))) + if hasattr(self, 'inpaint_tile_size'): self.inpaint_tile_size.set(int(pre.get('inpaint_tile_size', 512))) + if hasattr(self, 'inpaint_tile_overlap'): self.inpaint_tile_overlap.set(int(pre.get('inpaint_tile_overlap', 64))) + + # OCR basic + if hasattr(self, 'confidence_threshold'): self.confidence_threshold.set(float(ocr.get('confidence_threshold', 0.7))) + if hasattr(self, 'detection_mode'): self.detection_mode.set(str(ocr.get('text_detection_mode', 'document'))) + if hasattr(self, 'merge_nearby_threshold'): self.merge_nearby_threshold.set(int(ocr.get('merge_nearby_threshold', 20))) + if hasattr(self, 'enable_rotation'): self.enable_rotation.set(bool(ocr.get('enable_rotation_correction', True))) + + # Language checkboxes + try: + if hasattr(self, 'lang_vars') and isinstance(self.lang_vars, dict): + langs = set(ocr.get('language_hints', ['ja', 'ko', 'zh'])) + for code, var in self.lang_vars.items(): + var.set(code in langs) + except Exception: + pass + + # OCR batching/locality + if hasattr(self, 'ocr_batch_enabled_var'): self.ocr_batch_enabled_var.set(bool(ocr.get('ocr_batch_enabled', True))) + if hasattr(self, 'ocr_batch_size_var'): self.ocr_batch_size_var.set(int(ocr.get('ocr_batch_size', 8))) + if hasattr(self, 'ocr_max_conc_var'): self.ocr_max_conc_var.set(int(ocr.get('ocr_max_concurrency', 2))) + if hasattr(self, 'roi_locality_var'): self.roi_locality_var.set(bool(ocr.get('roi_locality_enabled', False))) + if hasattr(self, 'roi_padding_ratio_var'): self.roi_padding_ratio_var.set(float(ocr.get('roi_padding_ratio', 0.08))) + if hasattr(self, 'roi_min_side_var'): self.roi_min_side_var.set(int(ocr.get('roi_min_side_px', 12))) + if hasattr(self, 'roi_min_area_var'): self.roi_min_area_var.set(int(ocr.get('roi_min_area_px', 100))) + if hasattr(self, 'roi_max_side_var'): self.roi_max_side_var.set(int(ocr.get('roi_max_side', 0))) + + # English filters + if hasattr(self, 'exclude_english_var'): self.exclude_english_var.set(bool(ocr.get('exclude_english_text', False))) + if hasattr(self, 'english_exclude_threshold'): self.english_exclude_threshold.set(float(ocr.get('english_exclude_threshold', 0.7))) + if hasattr(self, 'english_exclude_min_chars'): self.english_exclude_min_chars.set(int(ocr.get('english_exclude_min_chars', 4))) + if hasattr(self, 'english_exclude_short_tokens'): self.english_exclude_short_tokens.set(bool(ocr.get('english_exclude_short_tokens', False))) + + # Azure (simplified - only merge multiplier remains) + if hasattr(self, 'azure_merge_multiplier'): self.azure_merge_multiplier.set(float(ocr.get('azure_merge_multiplier', 3.0))) + try: + if hasattr(self, '_update_azure_label'): + self._update_azure_label() + except Exception: + pass + + # Bubble detector + if hasattr(self, 'bubble_detection_enabled'): self.bubble_detection_enabled.set(bool(ocr.get('bubble_detection_enabled', False))) + # Detector type mapping to UI labels + if hasattr(self, 'detector_type'): + dt = str(ocr.get('detector_type', 'rtdetr_onnx')) + if dt == 'rtdetr_onnx': self.detector_type.set('RTEDR_onnx') + elif dt == 'rtdetr': self.detector_type.set('RT-DETR') + elif dt == 'yolo': self.detector_type.set('YOLOv8 Speech') + elif dt == 'custom': self.detector_type.set('Custom Model') + else: self.detector_type.set('RTEDR_onnx') + if hasattr(self, 'bubble_model_path'): self.bubble_model_path.set(str(ocr.get('bubble_model_path', ''))) + if hasattr(self, 'bubble_confidence'): self.bubble_confidence.set(float(ocr.get('bubble_confidence', 0.5))) + if hasattr(self, 'detect_empty_bubbles'): self.detect_empty_bubbles.set(bool(ocr.get('detect_empty_bubbles', True))) + if hasattr(self, 'detect_text_bubbles'): self.detect_text_bubbles.set(bool(ocr.get('detect_text_bubbles', True))) + if hasattr(self, 'detect_free_text'): self.detect_free_text.set(bool(ocr.get('detect_free_text', True))) + if hasattr(self, 'bubble_max_det_yolo_var'): self.bubble_max_det_yolo_var.set(int(ocr.get('bubble_max_detections_yolo', 100))) + + # Inpainting + if hasattr(self, 'inpaint_batch_size'): self.inpaint_batch_size.set(int(inp.get('batch_size', 1))) + if hasattr(self, 'enable_cache_var'): self.enable_cache_var.set(bool(inp.get('enable_cache', True))) + if hasattr(self, 'mask_dilation_var'): self.mask_dilation_var.set(int(s.get('mask_dilation', 0))) + if hasattr(self, 'use_all_iterations_var'): self.use_all_iterations_var.set(bool(s.get('use_all_iterations', True))) + if hasattr(self, 'all_iterations_var'): self.all_iterations_var.set(int(s.get('all_iterations', 2))) + if hasattr(self, 'text_bubble_iterations_var'): self.text_bubble_iterations_var.set(int(s.get('text_bubble_dilation_iterations', 2))) + if hasattr(self, 'empty_bubble_iterations_var'): self.empty_bubble_iterations_var.set(int(s.get('empty_bubble_dilation_iterations', 3))) + if hasattr(self, 'free_text_iterations_var'): self.free_text_iterations_var.set(int(s.get('free_text_dilation_iterations', 0))) + + # Advanced + if hasattr(self, 'format_detection'): self.format_detection.set(1 if adv.get('format_detection', True) else 0) + if hasattr(self, 'webtoon_mode'): self.webtoon_mode.set(str(adv.get('webtoon_mode', 'auto'))) + if hasattr(self, 'debug_mode'): self.debug_mode.set(1 if adv.get('debug_mode', False) else 0) + if hasattr(self, 'save_intermediate'): self.save_intermediate.set(1 if adv.get('save_intermediate', False) else 0) + if hasattr(self, 'parallel_processing'): self.parallel_processing.set(1 if adv.get('parallel_processing', False) else 0) + if hasattr(self, 'max_workers'): self.max_workers.set(int(adv.get('max_workers', 4))) + if hasattr(self, 'use_singleton_models'): self.use_singleton_models.set(bool(adv.get('use_singleton_models', True))) + if hasattr(self, 'auto_cleanup_models'): self.auto_cleanup_models.set(bool(adv.get('auto_cleanup_models', False))) + if hasattr(self, 'unload_models_var'): self.unload_models_var.set(bool(adv.get('unload_models_after_translation', False))) + if hasattr(self, 'parallel_panel_var'): self.parallel_panel_var.set(bool(adv.get('parallel_panel_translation', False))) + if hasattr(self, 'panel_max_workers_var'): self.panel_max_workers_var.set(int(adv.get('panel_max_workers', 2))) + if hasattr(self, 'panel_stagger_ms_var'): self.panel_stagger_ms_var.set(int(adv.get('panel_start_stagger_ms', 30))) + # New: preload local inpainting for parallel panels (default True) + if hasattr(self, 'preload_local_panels_var'): self.preload_local_panels_var.set(bool(adv.get('preload_local_inpainting_for_panels', True))) + if hasattr(self, 'auto_convert_onnx_var'): self.auto_convert_onnx_var.set(bool(adv.get('auto_convert_to_onnx', False))) + if hasattr(self, 'auto_convert_onnx_bg_var'): self.auto_convert_onnx_bg_var.set(bool(adv.get('auto_convert_to_onnx_background', True))) + if hasattr(self, 'quantize_models_var'): self.quantize_models_var.set(bool(adv.get('quantize_models', False))) + if hasattr(self, 'onnx_quantize_var'): self.onnx_quantize_var.set(bool(adv.get('onnx_quantize', False))) + if hasattr(self, 'torch_precision_var'): self.torch_precision_var.set(str(adv.get('torch_precision', 'auto'))) + + # Font sizing tab + if hasattr(self, 'font_algorithm_var'): self.font_algorithm_var.set(str(font.get('algorithm', 'smart'))) + if hasattr(self, 'min_font_size_var'): self.min_font_size_var.set(int(font.get('min_size', 10))) + if hasattr(self, 'max_font_size_var'): self.max_font_size_var.set(int(font.get('max_size', 40))) + if hasattr(self, 'min_readable_var'): self.min_readable_var.set(int(font.get('min_readable', 14))) + if hasattr(self, 'prefer_larger_var'): self.prefer_larger_var.set(bool(font.get('prefer_larger', True))) + if hasattr(self, 'bubble_size_factor_var'): self.bubble_size_factor_var.set(bool(font.get('bubble_size_factor', True))) + if hasattr(self, 'line_spacing_var'): self.line_spacing_var.set(float(font.get('line_spacing', 1.3))) + if hasattr(self, 'max_lines_var'): self.max_lines_var.set(int(font.get('max_lines', 10))) + try: + if hasattr(self, '_on_font_mode_change'): + self._on_font_mode_change() + except Exception: + pass + + # Rendering controls (if present in this dialog) + if hasattr(self, 'font_size_mode_var'): self.font_size_mode_var.set(str(s.get('rendering', {}).get('font_size_mode', 'auto'))) + if hasattr(self, 'fixed_font_size_var'): self.fixed_font_size_var.set(int(s.get('rendering', {}).get('fixed_font_size', 16))) + if hasattr(self, 'font_scale_var'): self.font_scale_var.set(float(s.get('rendering', {}).get('font_scale', 1.0))) + if hasattr(self, 'auto_fit_style_var'): self.auto_fit_style_var.set(str(s.get('rendering', {}).get('auto_fit_style', 'balanced'))) + + # Cloud API tab + if hasattr(self, 'cloud_model_var'): self.cloud_model_var.set(str(s.get('cloud_inpaint_model', 'ideogram-v2'))) + if hasattr(self, 'custom_version_var'): self.custom_version_var.set(str(s.get('cloud_custom_version', ''))) + if hasattr(self, 'cloud_prompt_var'): self.cloud_prompt_var.set(str(s.get('cloud_inpaint_prompt', 'clean background, smooth surface'))) + if hasattr(self, 'cloud_negative_prompt_var'): self.cloud_negative_prompt_var.set(str(s.get('cloud_negative_prompt', 'text, writing, letters'))) + if hasattr(self, 'cloud_steps_var'): self.cloud_steps_var.set(int(s.get('cloud_inference_steps', 20))) + if hasattr(self, 'cloud_timeout_var'): self.cloud_timeout_var.set(int(s.get('cloud_timeout', 60))) + + # Trigger dependent UI updates + try: + self._toggle_preprocessing() + except Exception: + pass + try: + if hasattr(self, '_on_cloud_model_change'): + self._on_cloud_model_change() + except Exception: + pass + try: + self._toggle_iteration_controls() + except Exception: + pass + try: + self._toggle_roi_locality_controls() + except Exception: + pass + try: + self._toggle_workers() + except Exception: + pass + + # Build/attach advanced control for local inpainting preload if not present + try: + if not hasattr(self, 'preload_local_panels_var') and hasattr(self, '_create_advanced_tab_ui'): + # If there is a helper to build advanced UI, we rely on it. Otherwise, attach to existing advanced frame if available. + pass + except Exception: + pass + try: + if hasattr(self, 'compression_format_combo'): + self._toggle_compression_format() + except Exception: + pass + try: + if hasattr(self, 'detector_type'): + self._on_detector_type_changed() + except Exception: + pass + try: + self.dialog.update_idletasks() + except Exception: + pass + except Exception: + # Best-effort application only + pass + + + def _set_font_preset(self, preset: str): + """Apply font sizing preset""" + if preset == 'small': + # For manga with small bubbles + self.font_algorithm_var.set('conservative') + self.min_font_size_var.set(8) + self.max_font_size_var.set(24) + self.min_readable_var.set(12) + self.prefer_larger_var.set(False) + self.bubble_size_factor_var.set(True) + self.line_spacing_var.set(1.2) + self.max_lines_var.set(8) + elif preset == 'balanced': + # Default balanced settings + self.font_algorithm_var.set('smart') + self.min_font_size_var.set(10) + self.max_font_size_var.set(40) + self.min_readable_var.set(14) + self.prefer_larger_var.set(True) + self.bubble_size_factor_var.set(True) + self.line_spacing_var.set(1.3) + self.max_lines_var.set(10) + elif preset == 'large': + # For maximum readability + self.font_algorithm_var.set('aggressive') + self.min_font_size_var.set(14) + self.max_font_size_var.set(50) + self.min_readable_var.set(16) + self.prefer_larger_var.set(True) + self.bubble_size_factor_var.set(False) + self.line_spacing_var.set(1.4) + self.max_lines_var.set(12) + + + def _save_rendering_settings(self, *args): + """Auto-save font and rendering settings when controls change""" + # Don't save during initialization + if hasattr(self, '_initializing') and self._initializing: + return + + try: + # Ensure rendering section exists in settings + if 'rendering' not in self.settings: + self.settings['rendering'] = {} + + # Save font size controls if they exist + if hasattr(self, 'font_size_mode_var'): + self.settings['rendering']['font_size_mode'] = self.font_size_mode_var.get() + self.settings['rendering']['fixed_font_size'] = self.fixed_font_size_var.get() + self.settings['rendering']['font_scale'] = self.font_scale_var.get() + self.settings['rendering']['auto_fit_style'] = self.auto_fit_style_var.get() + + # Save min/max for auto mode + if hasattr(self, 'min_font_size_var'): + self.settings['rendering']['auto_min_size'] = self.min_font_size_var.get() + if hasattr(self, 'max_font_size_var'): + self.settings['rendering']['auto_max_size'] = self.max_font_size_var.get() + + # Update config + self.config['manga_settings'] = self.settings + + # Mirror only auto max to top-level config for backward compatibility; keep min nested + try: + auto_max = self.settings.get('rendering', {}).get('auto_max_size', None) + if auto_max is not None: + self.config['manga_max_font_size'] = int(auto_max) + except Exception: + pass + + # Save to file immediately + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config() + print(f"Auto-saved rendering settings") + time.sleep(0.1) # Brief pause for stability + print("💤 Auto-save pausing briefly for stability") + + except Exception as e: + print(f"Error auto-saving rendering settings: {e}") + + def _save_settings(self): + """Save all settings including expanded iteration controls""" + try: + # Collect all preprocessing settings + self.settings['preprocessing']['enabled'] = self.preprocess_enabled.isChecked() + self.settings['preprocessing']['auto_detect_quality'] = self.auto_detect.isChecked() + self.settings['preprocessing']['contrast_threshold'] = self.contrast_threshold.value() + self.settings['preprocessing']['sharpness_threshold'] = self.sharpness_threshold.value() + self.settings['preprocessing']['enhancement_strength'] = self.enhancement_strength.value() + self.settings['preprocessing']['noise_threshold'] = self.noise_threshold.value() + self.settings['preprocessing']['denoise_strength'] = self.denoise_strength.value() + self.settings['preprocessing']['max_image_dimension'] = self.dimension_spinbox.value() + self.settings['preprocessing']['max_image_pixels'] = self.pixels_spinbox.value() + self.settings['preprocessing']['chunk_height'] = self.chunk_height_spinbox.value() + self.settings['preprocessing']['chunk_overlap'] = self.chunk_overlap_spinbox.value() + + # Compression (saved separately from preprocessing) + if 'compression' not in self.settings: + self.settings['compression'] = {} + self.settings['compression']['enabled'] = bool(self.compression_enabled.isChecked()) + self.settings['compression']['format'] = str(self.compression_format_combo.currentText()) + self.settings['compression']['jpeg_quality'] = int(self.jpeg_quality_spin.value()) + self.settings['compression']['png_compress_level'] = int(self.png_level_spin.value()) + self.settings['compression']['webp_quality'] = int(self.webp_quality_spin.value()) + + # TILING SETTINGS - save under preprocessing (primary) and mirror under 'tiling' for backward compatibility + self.settings['preprocessing']['inpaint_tiling_enabled'] = self.inpaint_tiling_enabled.isChecked() + self.settings['preprocessing']['inpaint_tile_size'] = self.tile_size_spinbox.value() + self.settings['preprocessing']['inpaint_tile_overlap'] = self.tile_overlap_spinbox.value() + # Back-compat mirror + self.settings['tiling'] = { + 'enabled': self.inpaint_tiling_enabled.isChecked(), + 'tile_size': self.tile_size_spinbox.value(), + 'tile_overlap': self.tile_overlap_spinbox.value() + } + + # OCR settings + self.settings['ocr']['language_hints'] = [code for code, checkbox in self.lang_checkboxes.items() if checkbox.isChecked()] + # Save as cloud_ocr_confidence (applies to Google/Azure only) + self.settings['ocr']['cloud_ocr_confidence'] = self.confidence_threshold_slider.value() / 100.0 + # Keep old setting for backward compatibility + self.settings['ocr']['confidence_threshold'] = self.settings['ocr']['cloud_ocr_confidence'] + self.settings['ocr']['text_detection_mode'] = self.detection_mode_combo.currentText() + self.settings['ocr']['min_region_size'] = self.min_region_size_spinbox.value() + self.settings['ocr']['merge_nearby_threshold'] = self.merge_nearby_threshold_spinbox.value() + self.settings['ocr']['enable_rotation_correction'] = self.enable_rotation_checkbox.isChecked() + # Azure settings - only merge multiplier remains (new API is synchronous) + self.settings['ocr']['azure_merge_multiplier'] = self.azure_merge_multiplier_slider.value() / 100.0 + self.settings['ocr']['min_text_length'] = self.min_text_length_spinbox.value() + self.settings['ocr']['exclude_english_text'] = self.exclude_english_checkbox.isChecked() + + # OCR batching & locality + self.settings['ocr']['ocr_batch_enabled'] = bool(self.ocr_batch_enabled_checkbox.isChecked()) + self.settings['ocr']['ocr_batch_size'] = int(self.ocr_batch_size_spinbox.value()) + self.settings['ocr']['ocr_max_concurrency'] = int(self.ocr_max_conc_spinbox.value()) + self.settings['ocr']['roi_locality_enabled'] = bool(self.roi_locality_checkbox.isChecked()) + self.settings['ocr']['roi_padding_ratio'] = float(self.roi_padding_ratio_slider.value() / 100.0) + self.settings['ocr']['roi_min_side_px'] = int(self.roi_min_side_spinbox.value()) + self.settings['ocr']['roi_min_area_px'] = int(self.roi_min_area_spinbox.value()) + self.settings['ocr']['roi_max_side'] = int(self.roi_max_side_spinbox.value()) + self.settings['ocr']['english_exclude_threshold'] = self.english_exclude_threshold_slider.value() / 100.0 + self.settings['ocr']['english_exclude_min_chars'] = self.english_exclude_min_chars_spinbox.value() + self.settings['ocr']['english_exclude_short_tokens'] = self.english_exclude_short_tokens_checkbox.isChecked() + + # Bubble detection settings + self.settings['ocr']['bubble_detection_enabled'] = self.bubble_detection_enabled_checkbox.isChecked() + self.settings['ocr']['use_rtdetr_for_ocr_regions'] = self.use_rtdetr_for_ocr_checkbox.isChecked() # NEW: RT-DETR for OCR guidance + self.settings['ocr']['bubble_model_path'] = self.bubble_model_entry.text() + self.settings['ocr']['bubble_confidence'] = self.bubble_conf_slider.value() / 100.0 + self.settings['ocr']['rtdetr_confidence'] = self.bubble_conf_slider.value() / 100.0 + self.settings['ocr']['detect_empty_bubbles'] = self.detect_empty_bubbles_checkbox.isChecked() + self.settings['ocr']['detect_text_bubbles'] = self.detect_text_bubbles_checkbox.isChecked() + self.settings['ocr']['detect_free_text'] = self.detect_free_text_checkbox.isChecked() + self.settings['ocr']['rtdetr_model_url'] = self.bubble_model_entry.text() + self.settings['ocr']['bubble_max_detections_yolo'] = int(self.bubble_max_det_yolo_spinbox.value()) + self.settings['ocr']['rtdetr_max_concurrency'] = int(self.rtdetr_max_concurrency_spinbox.value()) + + # Save the detector type properly + detector_display = self.detector_type_combo.currentText() + if 'RTEDR_onnx' in detector_display or 'ONNX' in detector_display.upper(): + self.settings['ocr']['detector_type'] = 'rtdetr_onnx' + elif 'RT-DETR' in detector_display: + self.settings['ocr']['detector_type'] = 'rtdetr' + elif 'YOLOv8' in detector_display: + self.settings['ocr']['detector_type'] = 'yolo' + elif detector_display == 'Custom Model': + self.settings['ocr']['detector_type'] = 'custom' + self.settings['ocr']['custom_model_path'] = self.bubble_model_entry.text() + else: + self.settings['ocr']['detector_type'] = 'rtdetr_onnx' + + # Inpainting settings + if 'inpainting' not in self.settings: + self.settings['inpainting'] = {} + self.settings['inpainting']['batch_size'] = self.inpaint_batch_size_spinbox.value() + self.settings['inpainting']['enable_cache'] = self.enable_cache_checkbox.isChecked() + + # Save all dilation settings + self.settings['mask_dilation'] = self.mask_dilation_spinbox.value() + self.settings['dilation_kernel_size'] = self.kernel_size_spinbox.value() + self.settings['use_all_iterations'] = self.use_all_iterations_checkbox.isChecked() + self.settings['all_iterations'] = self.all_iterations_spinbox.value() + self.settings['text_bubble_dilation_iterations'] = self.text_bubble_iter_spinbox.value() + self.settings['empty_bubble_dilation_iterations'] = self.empty_bubble_iter_spinbox.value() + self.settings['free_text_dilation_iterations'] = self.free_text_iter_spinbox.value() + self.settings['auto_iterations'] = self.auto_iterations_checkbox.isChecked() + + # Legacy support + self.settings['bubble_dilation_iterations'] = self.text_bubble_iter_spinbox.value() + self.settings['dilation_iterations'] = self.text_bubble_iter_spinbox.value() + + # Advanced settings + self.settings['advanced']['format_detection'] = bool(self.format_detection_checkbox.isChecked()) + self.settings['advanced']['webtoon_mode'] = self.webtoon_mode_combo.currentText() + self.settings['advanced']['debug_mode'] = bool(self.debug_mode_checkbox.isChecked()) + self.settings['advanced']['save_intermediate'] = bool(self.save_intermediate_checkbox.isChecked()) + self.settings['advanced']['parallel_processing'] = bool(self.parallel_processing_checkbox.isChecked()) + self.settings['advanced']['max_workers'] = self.max_workers_spinbox.value() + + # Save HD strategy settings + self.settings['advanced']['hd_strategy'] = str(self.hd_strategy_combo.currentText()) + self.settings['advanced']['hd_strategy_resize_limit'] = int(self.hd_resize_limit_spin.value()) + self.settings['advanced']['hd_strategy_crop_margin'] = int(self.hd_crop_margin_spin.value()) + self.settings['advanced']['hd_strategy_crop_trigger_size'] = int(self.hd_crop_trigger_spin.value()) + # Also reflect into environment for immediate effect in this session + os.environ['HD_STRATEGY'] = self.settings['advanced']['hd_strategy'] + os.environ['HD_RESIZE_LIMIT'] = str(self.settings['advanced']['hd_strategy_resize_limit']) + os.environ['HD_CROP_MARGIN'] = str(self.settings['advanced']['hd_strategy_crop_margin']) + os.environ['HD_CROP_TRIGGER'] = str(self.settings['advanced']['hd_strategy_crop_trigger_size']) + + # Save parallel rendering toggle + if hasattr(self, 'render_parallel_checkbox'): + self.settings['advanced']['render_parallel'] = bool(self.render_parallel_checkbox.isChecked()) + + # Panel-level parallel translation settings + self.settings['advanced']['parallel_panel_translation'] = bool(self.parallel_panel_checkbox.isChecked()) + self.settings['advanced']['panel_max_workers'] = int(self.panel_max_workers_spinbox.value()) + self.settings['advanced']['panel_start_stagger_ms'] = int(self.panel_stagger_ms_spinbox.value()) + # New: preload local inpainting for panels + if hasattr(self, 'preload_local_panels_checkbox'): + self.settings['advanced']['preload_local_inpainting_for_panels'] = bool(self.preload_local_panels_checkbox.isChecked()) + + # Memory management settings + self.settings['advanced']['use_singleton_models'] = bool(self.use_singleton_models_checkbox.isChecked()) + self.settings['advanced']['auto_cleanup_models'] = bool(self.auto_cleanup_models_checkbox.isChecked()) + self.settings['advanced']['unload_models_after_translation'] = bool(self.unload_models_checkbox.isChecked() if hasattr(self, 'unload_models_checkbox') else False) + + # ONNX auto-convert settings (persist and apply to environment) + if hasattr(self, 'auto_convert_onnx_checkbox'): + self.settings['advanced']['auto_convert_to_onnx'] = bool(self.auto_convert_onnx_checkbox.isChecked()) + os.environ['AUTO_CONVERT_TO_ONNX'] = 'true' if self.auto_convert_onnx_checkbox.isChecked() else 'false' + if hasattr(self, 'auto_convert_onnx_bg_checkbox'): + self.settings['advanced']['auto_convert_to_onnx_background'] = bool(self.auto_convert_onnx_bg_checkbox.isChecked()) + os.environ['AUTO_CONVERT_TO_ONNX_BACKGROUND'] = 'true' if self.auto_convert_onnx_bg_checkbox.isChecked() else 'false' + + # Quantization toggles and precision + if hasattr(self, 'quantize_models_checkbox'): + self.settings['advanced']['quantize_models'] = bool(self.quantize_models_checkbox.isChecked()) + os.environ['MODEL_QUANTIZE'] = 'true' if self.quantize_models_checkbox.isChecked() else 'false' + if hasattr(self, 'onnx_quantize_checkbox'): + self.settings['advanced']['onnx_quantize'] = bool(self.onnx_quantize_checkbox.isChecked()) + os.environ['ONNX_QUANTIZE'] = 'true' if self.onnx_quantize_checkbox.isChecked() else 'false' + if hasattr(self, 'torch_precision_combo'): + self.settings['advanced']['torch_precision'] = str(self.torch_precision_combo.currentText()) + os.environ['TORCH_PRECISION'] = self.settings['advanced']['torch_precision'] + + # Memory cleanup toggle + if hasattr(self, 'force_deep_cleanup_checkbox'): + if 'advanced' not in self.settings: + self.settings['advanced'] = {} + self.settings['advanced']['force_deep_cleanup_each_image'] = bool(self.force_deep_cleanup_checkbox.isChecked()) + + # RAM cap settings + if hasattr(self, 'ram_cap_enabled_checkbox'): + self.settings['advanced']['ram_cap_enabled'] = bool(self.ram_cap_enabled_checkbox.isChecked()) + if hasattr(self, 'ram_cap_mb_spinbox'): + self.settings['advanced']['ram_cap_mb'] = int(self.ram_cap_mb_spinbox.value()) + if hasattr(self, 'ram_cap_mode_combo'): + mode = self.ram_cap_mode_combo.currentText() + self.settings['advanced']['ram_cap_mode'] = 'hard' if mode.startswith('hard') else 'soft' + if hasattr(self, 'ram_gate_timeout_spinbox'): + self.settings['advanced']['ram_gate_timeout_sec'] = float(self.ram_gate_timeout_spinbox.value()) + if hasattr(self, 'ram_gate_floor_spinbox'): + self.settings['advanced']['ram_min_floor_over_baseline_mb'] = int(self.ram_gate_floor_spinbox.value()) + + # Cloud API settings + if hasattr(self, 'cloud_model_selected'): + self.settings['cloud_inpaint_model'] = self.cloud_model_selected + self.settings['cloud_custom_version'] = self.custom_version_entry.text() + self.settings['cloud_inpaint_prompt'] = self.cloud_prompt_entry.text() + self.settings['cloud_negative_prompt'] = self.negative_entry.text() + self.settings['cloud_inference_steps'] = self.steps_spinbox.value() + self.settings['cloud_timeout'] = self.cloud_timeout_spinbox.value() + + # Clear bubble detector cache to force reload with new settings + if hasattr(self.main_gui, 'manga_tab') and hasattr(self.main_gui.manga_tab, 'translator'): + if hasattr(self.main_gui.manga_tab.translator, 'bubble_detector'): + self.main_gui.manga_tab.translator.bubble_detector = None + + # Save to config + self.config['manga_settings'] = self.settings + + # Save to file - using the correct method name + try: + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + print("Settings saved successfully") + elif hasattr(self.main_gui, 'save_configuration'): + self.main_gui.save_configuration() + print("Settings saved successfully") + else: + # Try direct save as fallback + if hasattr(self.main_gui, 'config_file'): + with open(self.main_gui.config_file, 'w') as f: + json.dump(self.config, f, indent=2) + print("Settings saved directly to config file") + except Exception as e: + print(f"Error saving configuration: {e}") + QMessageBox.critical(self, "Save Error", f"Failed to save settings: {e}") + return + + # Call callback if provided + if self.callback: + try: + self.callback(self.settings) + except Exception as e: + print(f"Error in callback: {e}") + + # Close dialog + self.accept() + + except Exception as e: + import traceback + print(f"Critical error in _save_settings: {e}") + print(traceback.format_exc()) + QMessageBox.critical(self, "Save Error", f"Failed to save settings: {e}") + + def _reset_defaults(self): + """Reset by removing manga_settings from config and reinitializing the dialog.""" + reply = QMessageBox.question(self, "Reset Settings", + "Reset all manga settings to defaults?\nThis will remove custom manga settings from config.json.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if reply != QMessageBox.StandardButton.Yes: + return + # Remove manga_settings key to force defaults + try: + if isinstance(self.config, dict) and 'manga_settings' in self.config: + del self.config['manga_settings'] + except Exception: + pass + # Persist changes WITHOUT showing message + try: + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + elif hasattr(self.main_gui, 'save_configuration'): + self.main_gui.save_configuration() + elif hasattr(self.main_gui, 'config_file') and isinstance(self.main_gui.config_file, str): + with open(self.main_gui.config_file, 'w', encoding='utf-8') as f: + json.dump(self.config, f, ensure_ascii=False, indent=2) + except Exception: + try: + if hasattr(self.main_gui, 'CONFIG_FILE') and isinstance(self.main_gui.CONFIG_FILE, str): + with open(self.main_gui.CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(self.config, f, ensure_ascii=False, indent=2) + except Exception: + pass + # Close and reopen dialog so defaults apply + self.close() + try: + MangaSettingsDialog(parent=self.parent, main_gui=self.main_gui, config=self.config, callback=self.callback) + except Exception: + pass # Don't show any message + + def _cancel(self): + """Cancel without saving""" + self.reject()