From a71d363f4f6ae27d59c9b8802c68b397607f17d1 Mon Sep 17 00:00:00 2001 From: Janusch Patas Date: Sat, 4 Jan 2025 11:55:39 +0100 Subject: [PATCH 1/5] cleanup structure --- .github/workflows/validate-and-build.yml | 198 +------------------- requirements.txt | 9 + src/generate_all.py | 16 -- src/generate_html.py | 27 ++- src/generate_thumbnails.py | 224 ----------------------- src/validate_yaml.py | 176 ++++++++++++++++++ 6 files changed, 214 insertions(+), 436 deletions(-) delete mode 100644 src/generate_all.py delete mode 100644 src/generate_thumbnails.py create mode 100644 src/validate_yaml.py diff --git a/.github/workflows/validate-and-build.yml b/.github/workflows/validate-and-build.yml index d5e3efd..68987e2 100644 --- a/.github/workflows/validate-and-build.yml +++ b/.github/workflows/validate-and-build.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pyyaml requests urllib3 PyGithub + pip install -r requirements.txt - name: Validate Changed YAML entries env: @@ -34,199 +34,11 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} run: | - python - < None: +def generate_html(entries: List[Dict[str, Any]], output_file: str) -> None: """Generate optimized HTML page while preserving design""" # Get all unique tags and years all_tags = sorted(set(tag for entry in entries for tag in entry['tags'])) @@ -531,7 +533,7 @@ def generate_html(entries: List[Dict[str, Any]]) -> None: """ - with open('index.html', 'w') as file: + with open(output_file, 'w') as file: file.write(html) def generate_year_options(entries: List[Dict[str, Any]]) -> str: @@ -621,4 +623,23 @@ def generate_paper_cards(entries: List[Dict[str, Any]]) -> str: """ cards.append(card) - return '\n'.join(cards) \ No newline at end of file + return '\n'.join(cards) + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python generate_html.py ") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] + + try: + with open(input_file) as stream: + entries = yaml.safe_load(stream) + + generate_html(entries, output_file) + print(f"Successfully generated {output_file}") + + except Exception as e: + print(f"Error: {str(e)}") + sys.exit(1) diff --git a/src/generate_thumbnails.py b/src/generate_thumbnails.py deleted file mode 100644 index acdfad5..0000000 --- a/src/generate_thumbnails.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import yaml -import requests -from pdf2image import convert_from_bytes -from PIL import Image -import logging -import os -import argparse -from concurrent.futures import ThreadPoolExecutor, as_completed -from tqdm import tqdm - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -class ThumbnailGenerator: - def __init__(self, output_dir: str = "assets/thumbnails"): - """Initialize thumbnail generator with fixed dimensions""" - self.output_dir = Path(output_dir) - self.output_dir.mkdir(parents=True, exist_ok=True) - # Fixed dimensions for all thumbnails - self.THUMB_WIDTH = 300 - self.THUMB_HEIGHT = 424 # Roughly A4 proportion - - def resize_existing_thumbnail(self, thumb_path: Path) -> bool: - """Resize an existing thumbnail to match required dimensions""" - try: - with Image.open(thumb_path) as img: - if img.size == (self.THUMB_WIDTH, self.THUMB_HEIGHT): - return True # Already correct size - - # Create white background - background = Image.new('RGB', (self.THUMB_WIDTH, self.THUMB_HEIGHT), 'white') - - # Resize maintaining aspect ratio - img.thumbnail((self.THUMB_WIDTH, self.THUMB_HEIGHT), Image.Resampling.LANCZOS) - - # Center the image - offset = ((self.THUMB_WIDTH - img.width) // 2, - (self.THUMB_HEIGHT - img.height) // 2) - background.paste(img, offset) - - # Save with temporary name first - temp_path = thumb_path.with_suffix('.tmp.jpg') - background.save(temp_path, "JPEG", quality=85, optimize=True) - - # Replace original file - temp_path.replace(thumb_path) - logger.debug(f"Resized existing thumbnail: {thumb_path.name}") - return True - - except Exception as e: - logger.error(f"Error resizing thumbnail {thumb_path}: {str(e)}") - return False - - def check_and_fix_thumbnail(self, paper_id: str) -> bool: - """Check if thumbnail exists and has correct dimensions, fix if needed""" - thumb_path = self.output_dir / f"{paper_id}.jpg" - - if not thumb_path.exists(): - return False - - try: - with Image.open(thumb_path) as img: - if img.size == (self.THUMB_WIDTH, self.THUMB_HEIGHT): - return True - - # Wrong size - resize it - logger.debug(f"Thumbnail exists but wrong size for {paper_id}, resizing...") - return self.resize_existing_thumbnail(thumb_path) - - except Exception as e: - logger.error(f"Error checking thumbnail {paper_id}: {str(e)}") - return False - - def download_pdf(self, url: str) -> bytes: - """Download PDF with proper headers and content type checking""" - headers = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'application/pdf,*/*' - } - - # Special handling for OpenReview URLs - if 'openreview.net' in url: - headers['Accept'] = 'application/pdf' - - response = requests.get(url, headers=headers, timeout=30) - response.raise_for_status() - - # Check content type - content_type = response.headers.get('Content-Type', '').lower() - if 'pdf' not in content_type and not url.endswith('.pdf'): - logger.warning(f"Warning: Content-Type is {content_type} for {url}") - - return response.content - - def create_thumbnail_from_pdf(self, pdf_content: bytes, paper_id: str) -> bool: - """Create fixed-size thumbnail from PDF content""" - try: - images = convert_from_bytes( - pdf_content, - first_page=1, - last_page=1, - size=(self.THUMB_WIDTH, self.THUMB_HEIGHT) - ) - - if not images: - return False - - # Create white background image - background = Image.new('RGB', (self.THUMB_WIDTH, self.THUMB_HEIGHT), 'white') - - # Paste the PDF image onto the background, maintaining aspect ratio - thumb = images[0] - thumb.thumbnail((self.THUMB_WIDTH, self.THUMB_HEIGHT), Image.Resampling.LANCZOS) - - # Center the thumbnail - offset = ((self.THUMB_WIDTH - thumb.width) // 2, - (self.THUMB_HEIGHT - thumb.height) // 2) - background.paste(thumb, offset) - - # Save thumbnail - thumb_path = self.output_dir / f"{paper_id}.jpg" - background.save(thumb_path, "JPEG", quality=85, optimize=True) - logger.debug(f"Created new thumbnail for {paper_id}") - return True - - except Exception as e: - logger.error(f"Error creating thumbnail for {paper_id}: {str(e)}") - return False - - def process_paper(self, paper: dict) -> tuple[str, bool]: - """Process a single paper entry""" - paper_id = paper['id'] - - # First check if thumbnail exists and has correct dimensions - if self.check_and_fix_thumbnail(paper_id): - logger.debug(f"Thumbnail already exists and correct for {paper_id}") - return paper_id, True - - # If we need to create new thumbnail, check if we have PDF URL - if not paper.get('paper'): - logger.warning(f"No PDF URL for {paper_id}") - return paper_id, False - - try: - logger.debug(f"Downloading PDF for {paper_id}") - pdf_content = self.download_pdf(paper['paper']) - success = self.create_thumbnail_from_pdf(pdf_content, paper_id) - return paper_id, success - except Exception as e: - logger.error(f"Error processing {paper_id}: {str(e)}") - return paper_id, False - - def generate_all(self, yaml_path: str, max_workers: int = 4): - """Generate thumbnails for all papers using parallel processing""" - # Load YAML data - with open(yaml_path) as f: - papers = yaml.safe_load(f) - - logger.info(f"Processing {len(papers)} papers using {max_workers} workers") - - # Process papers in parallel with progress bar - successful = 0 - with ThreadPoolExecutor(max_workers=max_workers) as executor: - # Create futures for all papers - future_to_paper = { - executor.submit(self.process_paper, paper): paper - for paper in papers - } - - # Process results with progress bar - with tqdm(total=len(papers), desc="Generating thumbnails") as pbar: - for future in as_completed(future_to_paper): - paper = future_to_paper[future] - try: - paper_id, success = future.result() - if success: - successful += 1 - paper['thumbnail'] = f"{self.output_dir}/{paper_id}.jpg" - except Exception as e: - logger.error(f"Error processing paper {paper.get('id')}: {str(e)}") - pbar.update(1) - - logger.info(f"Successfully processed {successful}/{len(papers)} thumbnails") - return papers - -def main(): - parser = argparse.ArgumentParser(description="Generate thumbnails from PDF papers") - parser.add_argument( - "--yaml", - default="awesome_3dgs_papers.yaml", - help="Path to YAML file containing paper information" - ) - parser.add_argument( - "--output", - default="assets/thumbnails", - help="Output directory for thumbnails" - ) - parser.add_argument( - "--workers", - type=int, - default=4, - help="Number of worker threads for parallel processing" - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Enable verbose logging" - ) - - args = parser.parse_args() - - if args.verbose: - logger.setLevel(logging.DEBUG) - - generator = ThumbnailGenerator(args.output) - generator.generate_all(args.yaml, args.workers) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/validate_yaml.py b/src/validate_yaml.py new file mode 100644 index 0000000..a1f81d2 --- /dev/null +++ b/src/validate_yaml.py @@ -0,0 +1,176 @@ +import yaml +import sys +import os +import requests +import time +from urllib3.util.retry import Retry +from requests.adapters import HTTPAdapter +from github import Github + +# Configure requests for better reliability +session = requests.Session() +retries = Retry( + total=3, + backoff_factor=1, + status_forcelist=[408, 429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET"] +) +adapter = HTTPAdapter(max_retries=retries) +session.mount('http://', adapter) +session.mount('https://', adapter) + +headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' +} + +allowed_tags = [ + "2DGS", "360 degree", "Acceleration", "Antialiasing", "Autonomous Driving", + "Avatar", "Classic Work", "Code", "Compression", "Deblurring", "Densification", + "Diffusion", "Distributed", "Dynamic", "Editing", "Event Camera", "Feed-Forward", + "GAN", "Inpainting", "In the Wild", "Language Embedding", "Large-Scale", "Lidar", + "Medicine", "Meshing", "Misc", "Monocular", "Perspective-correct", "Object Detection", + "Optimization", "Physics", "Point Cloud", "Poses", "Project", "Ray Tracing", + "Rendering", "Relight", "Review", "Robotics", "Segmentation", "SLAM", "Sparse", + "Stereo", "Style Transfer", "Texturing", "Transformer", "Uncertainty", "Video", + "Virtual Reality", "World Generation" +] + +def validate_url(url, required=False): + """Validate URL with fallback to GET if HEAD fails""" + if not url: + return None if not required else "URL is missing or empty" + + try: + # First try HEAD request + response = session.head(url, headers=headers, timeout=30, allow_redirects=True) + + # If HEAD fails, try GET + if response.status_code in [405, 400, 403]: + response = session.get(url, headers=headers, timeout=30, allow_redirects=True, stream=True) + response.close() + + valid_codes = {200, 301, 302, 303, 307, 308} + if response.status_code not in valid_codes: + return f"URL returns {response.status_code}" + + return None + + except requests.Timeout: + return "URL timed out" + except requests.RequestException as e: + return f"Error accessing URL: {str(e)}" + except Exception as e: + return f"Unexpected error: {str(e)}" + +def get_changed_entries(): + """Get entries that were changed or added in this PR""" + # Initialize GitHub client + g = Github(os.getenv('GITHUB_TOKEN')) + repo = g.get_repo(os.getenv('REPO')) + pr = repo.get_pull(int(os.getenv('PR_NUMBER'))) + + # Load both versions of the YAML file + with open("awesome_3dgs_papers.yaml", 'r') as file: + new_yaml = yaml.safe_load(file) + + try: + # Get base content + base_content = repo.get_contents("awesome_3dgs_papers.yaml", ref=pr.base.sha).decoded_content.decode() + base_yaml = yaml.safe_load(base_content) + except: + # If file doesn't exist in base, all entries are new + base_yaml = [] + + # Create dictionary of existing entries by ID + base_entries = {entry['id']: entry for entry in base_yaml} if base_yaml else {} + + # Find changed or new entries + changed_entries = [] + for entry in new_yaml: + entry_id = entry['id'] + if entry_id not in base_entries: + print(f"New entry found: {entry_id}") + changed_entries.append(entry) + elif entry != base_entries[entry_id]: + print(f"Modified entry found: {entry_id}") + changed_entries.append(entry) + + return changed_entries + +def validate_entries(entries): + """Validate the specified entries""" + errors = [] + url_fields = { + 'paper': True, + 'project_page': False, + 'code': False, + 'video': False + } + + # Load full YAML to get entry numbers + with open("awesome_3dgs_papers.yaml", 'r') as file: + all_entries = yaml.safe_load(file) + + # Create index lookup + entry_indices = {entry['id']: idx + 1 for idx, entry in enumerate(all_entries)} + + for entry in entries: + # Basic validation + if not entry.get('id'): + errors.append("Entry missing ID") + continue + + entry_num = entry_indices.get(entry['id'], '?') + print(f"\nValidating entry #{entry_num}: {entry['id']}") + + # Tags validation + tags = entry.get('tags', []) + if not tags: + errors.append(f"Entry {entry['id']}: No tags provided") + else: + invalid_tags = [tag for tag in tags if not tag.startswith('Year ') and tag not in allowed_tags] + if invalid_tags: + errors.append(f"Entry {entry['id']}: Invalid tags: {invalid_tags}") + + non_year_tags = [tag for tag in tags if not tag.startswith('Year ')] + if not non_year_tags: + errors.append(f"Entry {entry['id']}: Must have at least one non-Year tag") + + # URL validation + for field, required in url_fields.items(): + value = entry.get(field) + if value or required: + print(f"Checking {field} URL: {value}") + error = validate_url(value, required) + if error: + errors.append(f"Entry {entry['id']}: {field} {error}") + # Add delay between requests + time.sleep(1) + + return errors + +def main(): + try: + changed_entries = get_changed_entries() + if not changed_entries: + print("No entries were changed in this PR") + sys.exit(0) + + print(f"\nFound {len(changed_entries)} changed/new entries to validate") + errors = validate_entries(changed_entries) + + if errors: + print("\n❌ Validation errors found:") + for error in errors: + print(error) + sys.exit(1) + else: + print("\n✅ All changed entries passed validation!") + + except Exception as e: + print(f"\n❌ Error during validation: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() From ace9c9899d71de0d0e5e30a7a725518f1f01c394 Mon Sep 17 00:00:00 2001 From: Janusch Patas Date: Sat, 4 Jan 2025 12:00:30 +0100 Subject: [PATCH 2/5] move files --- CONTRIBUTING.md | 2 +- arxiv_integration.py => src/arxiv_integration.py | 0 yaml_editor.py => src/yaml_editor.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename arxiv_integration.py => src/arxiv_integration.py (100%) rename yaml_editor.py => src/yaml_editor.py (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c719ea..dfe78e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ pip install -r requirements.txt 4. Run the YAML editor: ```bash -python yaml_editor.py +python src/yaml_editor.py ``` 5. Use the editor to: diff --git a/arxiv_integration.py b/src/arxiv_integration.py similarity index 100% rename from arxiv_integration.py rename to src/arxiv_integration.py diff --git a/yaml_editor.py b/src/yaml_editor.py similarity index 100% rename from yaml_editor.py rename to src/yaml_editor.py From 6b91b048c9e88db78ed53857234bf8f905a3cb2f Mon Sep 17 00:00:00 2001 From: Janusch Patas Date: Sat, 4 Jan 2025 12:02:21 +0100 Subject: [PATCH 3/5] test github actions --- assets/thumbnails/xu2024representing.jpg | Bin 0 -> 35026 bytes awesome_3dgs_papers.yaml | 40 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 assets/thumbnails/xu2024representing.jpg diff --git a/assets/thumbnails/xu2024representing.jpg b/assets/thumbnails/xu2024representing.jpg new file mode 100644 index 0000000000000000000000000000000000000000..93a2dea9216aede3c3038f14b856124ceb0b899f GIT binary patch literal 35026 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 literal 0 HcmV?d00001 diff --git a/awesome_3dgs_papers.yaml b/awesome_3dgs_papers.yaml index c5f803c..aa758c6 100644 --- a/awesome_3dgs_papers.yaml +++ b/awesome_3dgs_papers.yaml @@ -10442,3 +10442,43 @@ - Project - Video thumbnail: assets/thumbnails/mallick2024taming.jpg +- id: xu2024representing + title: Representing Long Volumetric Video with Temporal Gaussian Hierarchy + authors: Zhen Xu, Yinghao Xu, Zhiyuan Yu, Sida Peng, Jiaming Sun, Hujun Bao, Xiaowei + Zhou + year: '2024' + abstract: 'This paper aims to address the challenge of reconstructing long volumetric + videos from multi-view RGB videos. Recent dynamic view synthesis methods leverage + powerful 4D representations, like feature grids or point cloud sequences, to achieve + high-quality rendering results. However, they are typically limited to short (1~2s) + video clips and often suffer from large memory footprints when dealing with longer + videos. To solve this issue, we propose a novel 4D representation, named Temporal + Gaussian Hierarchy, to compactly model long volumetric videos. Our key observation + is that there are generally various degrees of temporal redundancy in dynamic + scenes, which consist of areas changing at different speeds. Motivated by this, + our approach builds a multi-level hierarchy of 4D Gaussian primitives, where each + level separately describes scene regions with different degrees of content change, + and adaptively shares Gaussian primitives to represent unchanged scene content + over different temporal segments, thus effectively reducing the number of Gaussian + primitives. In addition, the tree-like structure of the Gaussian hierarchy allows + us to efficiently represent the scene at a particular moment with a subset of + Gaussian primitives, leading to nearly constant GPU memory usage during the training + or rendering regardless of the video length. Extensive experimental results demonstrate + the superiority of our method over alternative methods in terms of training cost, + rendering speed, and storage usage. To our knowledge, this work is the first approach + capable of efficiently handling minutes of volumetric video data while maintaining + state-of-the-art rendering quality. Our project page is available at: https://zju3dv.github.io/longvolcap. + + ' + project_page: https://zju3dv.github.io/longvolcap/ + paper: https://arxiv.org/pdf/2412.09608.pdf + code: null + video: https://www.youtube.com/watch?v=y7e0YRNNmXw&feature=youtu.be + thumbnail_image: false + thumbnail_video: false + tags: + - Acceleration + - Dynamic + - Project + - Video + thumbnail: assets/thumbnails/xu2024representing.jpg From a99a9cea1166ccc0de033d00690ce0fbc8254313 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 4 Jan 2025 11:03:04 +0000 Subject: [PATCH 4/5] Update index.html --- index.html | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/index.html b/index.html index 8ca7ad9..a608dd7 100644 --- a/index.html +++ b/index.html @@ -14728,6 +14728,52 @@

+ +
+
+
+
+ Paper thumbnail for Representing Long Volumetric Video with Temporal Gaussian Hierarchy +
+
+

+ Representing Long Volumetric Video with Temporal Gaussian Hierarchy (2024) +

+

Zhen Xu, Yinghao Xu, Zhiyuan Yu, Sida Peng, Jiaming Sun, Hujun Bao, Xiaowei Zhou

+
+ Acceleration +Dynamic +Project +Video +
+ + + +
+ This paper aims to address the challenge of reconstructing long volumetric videos from multi-view RGB videos. Recent dynamic view synthesis methods leverage powerful 4D representations, like feature grids or point cloud sequences, to achieve high-quality rendering results. However, they are typically limited to short (1~2s) video clips and often suffer from large memory footprints when dealing with longer videos. To solve this issue, we propose a novel 4D representation, named Temporal Gaussian Hierarchy, to compactly model long volumetric videos. Our key observation is that there are generally various degrees of temporal redundancy in dynamic scenes, which consist of areas changing at different speeds. Motivated by this, our approach builds a multi-level hierarchy of 4D Gaussian primitives, where each level separately describes scene regions with different degrees of content change, and adaptively shares Gaussian primitives to represent unchanged scene content over different temporal segments, thus effectively reducing the number of Gaussian primitives. In addition, the tree-like structure of the Gaussian hierarchy allows us to efficiently represent the scene at a particular moment with a subset of Gaussian primitives, leading to nearly constant GPU memory usage during the training or rendering regardless of the video length. Extensive experimental results demonstrate the superiority of our method over alternative methods in terms of training cost, rendering speed, and storage usage. To our knowledge, this work is the first approach capable of efficiently handling minutes of volumetric video data while maintaining state-of-the-art rendering quality. Our project page is available at: https://zju3dv.github.io/longvolcap. + +
+ +
+
+
+ From d49953d3ce956193f6d587ff56fc742477467550 Mon Sep 17 00:00:00 2001 From: Janusch Patas Date: Sat, 4 Jan 2025 12:25:42 +0100 Subject: [PATCH 5/5] restructure --- editor.py | 4 + src/__init__.py | 0 src/components/__init__.py | 0 src/components/dialogs.py | 107 ++++++++++++ src/components/thumbnail.py | 62 +++++++ src/components/widgets.py | 41 +++++ src/yaml_editor.py | 321 +++--------------------------------- 7 files changed, 238 insertions(+), 297 deletions(-) create mode 100644 editor.py create mode 100644 src/__init__.py create mode 100644 src/components/__init__.py create mode 100644 src/components/dialogs.py create mode 100644 src/components/thumbnail.py create mode 100644 src/components/widgets.py diff --git a/editor.py b/editor.py new file mode 100644 index 0000000..79514ca --- /dev/null +++ b/editor.py @@ -0,0 +1,4 @@ +from src.yaml_editor import main + +if __name__ == '__main__': + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/components/__init__.py b/src/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/components/dialogs.py b/src/components/dialogs.py new file mode 100644 index 0000000..2ee5617 --- /dev/null +++ b/src/components/dialogs.py @@ -0,0 +1,107 @@ +import arxiv +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, + QMessageBox, QTextEdit, QScrollArea, QListWidget, + QGridLayout, QDialog) +import logging +from src.arxiv_integration import ArxivIntegration +from src.components.thumbnail import ThumbnailGenerator + +logger = logging.getLogger(__name__) + +class ArxivAddDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add from arXiv") + self.setup_ui() + self.arxiv = ArxivIntegration() + self.thumbnail_generator = ThumbnailGenerator() + self.client = arxiv.Client() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # URL input + input_layout = QHBoxLayout() + self.url_input = QLineEdit() + self.url_input.setPlaceholderText("Enter arXiv URL (e.g., https://arxiv.org/abs/2412.21206)") + self.add_button = QPushButton("Add Paper") + self.add_button.clicked.connect(self.add_paper) + input_layout.addWidget(self.url_input) + input_layout.addWidget(self.add_button) + layout.addLayout(input_layout) + + # Help text + help_text = QLabel("You can paste either:\n- Full arXiv URL (https://arxiv.org/abs/2412.21206)\n- arXiv ID (2412.21206)") + help_text.setStyleSheet("color: gray;") + layout.addWidget(help_text) + + # Status label + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + def generate_thumbnail(self, entry): + if not entry.get('paper'): + logger.warning(f"No PDF URL for {entry['id']}") + return False + + try: + self.status_label.setText("Generating thumbnail...") + QApplication.processEvents() # Update UI + + pdf_content = self.thumbnail_generator.download_pdf(entry['paper']) + success = self.thumbnail_generator.create_thumbnail(pdf_content, entry['id']) + + if success: + self.status_label.setText("Thumbnail generated successfully") + else: + self.status_label.setText("Failed to generate thumbnail") + + return success + + except Exception as e: + logger.error(f"Error generating thumbnail: {str(e)}") + self.status_label.setText(f"Error generating thumbnail: {str(e)}") + return False + + def add_paper(self): + url_or_id = self.url_input.text().strip() + if not url_or_id: + self.status_label.setText("Please enter an arXiv URL or ID") + return + + self.status_label.setText("Fetching paper information...") + self.add_button.setEnabled(False) + + try: + entry = self.arxiv.get_paper(url_or_id) + + if entry: + msg = QMessageBox() + msg.setIcon(QMessageBox.Icon.Question) + msg.setText(f"Found paper:\n\n{entry['title']}\n\nAdd this paper?") + msg.setWindowTitle("Confirm Paper") + msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + + if msg.exec() == QMessageBox.StandardButton.Yes: + if self.arxiv.append_to_yaml(entry): + thumbnail_success = self.generate_thumbnail(entry) + + if thumbnail_success: + QMessageBox.information(self, "Success", + "Paper added successfully and thumbnail generated!") + else: + QMessageBox.warning(self, "Partial Success", + "Paper added but failed to generate thumbnail.") + + self.accept() # Close dialog + else: + QMessageBox.warning(self, "Error", + "Failed to add paper. It might already exist.") + else: + self.status_label.setText("Could not find paper with given ID") + + except Exception as e: + self.status_label.setText(f"Error: {str(e)}") + finally: + self.add_button.setEnabled(True) \ No newline at end of file diff --git a/src/components/thumbnail.py b/src/components/thumbnail.py new file mode 100644 index 0000000..d642c91 --- /dev/null +++ b/src/components/thumbnail.py @@ -0,0 +1,62 @@ +from pathlib import Path +import requests +from pdf2image import convert_from_bytes +from PIL import Image +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class ThumbnailGenerator: + def __init__(self, output_dir: str = "assets/thumbnails"): + """Initialize thumbnail generator with fixed dimensions""" + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self.THUMB_WIDTH = 300 + self.THUMB_HEIGHT = 424 # Roughly A4 proportion + + def download_pdf(self, url: str) -> bytes: + """Download PDF with proper headers""" + headers = { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + 'Accept': 'application/pdf,*/*' + } + + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + return response.content + + def create_thumbnail(self, pdf_content: bytes, paper_id: str) -> bool: + """Create fixed-size thumbnail from PDF content""" + try: + images = convert_from_bytes( + pdf_content, + first_page=1, + last_page=1, + size=(self.THUMB_WIDTH, self.THUMB_HEIGHT) + ) + + if not images: + return False + + # Create white background + background = Image.new('RGB', (self.THUMB_WIDTH, self.THUMB_HEIGHT), 'white') + + # Paste the PDF image onto background + thumb = images[0] + thumb.thumbnail((self.THUMB_WIDTH, self.THUMB_HEIGHT), Image.Resampling.LANCZOS) + + # Center the thumbnail + offset = ((self.THUMB_WIDTH - thumb.width) // 2, + (self.THUMB_HEIGHT - thumb.height) // 2) + background.paste(thumb, offset) + + # Save thumbnail + thumb_path = self.output_dir / f"{paper_id}.jpg" + background.save(thumb_path, "JPEG", quality=85, optimize=True) + logger.info(f"Created thumbnail for {paper_id}") + return True + + except Exception as e: + logger.error(f"Error creating thumbnail for {paper_id}: {str(e)}") + return False \ No newline at end of file diff --git a/src/components/widgets.py b/src/components/widgets.py new file mode 100644 index 0000000..3aa155b --- /dev/null +++ b/src/components/widgets.py @@ -0,0 +1,41 @@ +from PyQt6.QtWidgets import QPushButton, QWidget, QHBoxLayout, QLabel, QLineEdit + +class TagButton(QPushButton): + def __init__(self, text, active=False): + super().__init__(text) + self.active = active + self.setCheckable(True) + self.setChecked(active) + self.setStyleSheet(""" + QPushButton { + padding: 5px 10px; + border-radius: 15px; + border: 1px solid #ccc; + background-color: white; + margin: 2px; + } + QPushButton:checked { + background-color: #007bff; + color: white; + border: none; + } + """) + self.setMinimumHeight(30) + +class URLWidget(QWidget): + def __init__(self, label_text): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.label = QLabel(label_text) + self.label.setMinimumWidth(100) + self.url_input = QLineEdit() + self.open_button = QPushButton("Open") + + layout.addWidget(self.label) + layout.addWidget(self.url_input) + layout.addWidget(self.open_button) + + def set_text(self, value): + self.url_input.setText("" if value is None else str(value)) \ No newline at end of file diff --git a/src/yaml_editor.py b/src/yaml_editor.py index dd1389a..d38dc55 100644 --- a/src/yaml_editor.py +++ b/src/yaml_editor.py @@ -3,246 +3,14 @@ import webbrowser from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, - QMessageBox, QDialog, QTextEdit, QScrollArea, QListWidget, - QGridLayout, ) + QMessageBox, QTextEdit, QScrollArea, QListWidget, + QGridLayout, QDialog) +from PyQt6.QtCore import QTimer +from PyQt6.QtCore import Qt from pathlib import Path -import requests -from pdf2image import convert_from_bytes -from PIL import Image -import logging -import arxiv -from arxiv_integration import ArxivIntegration -from PyQt6.QtCore import Qt, QTimer - -# Set up logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -class ThumbnailGenerator: - def __init__(self, output_dir: str = "assets/thumbnails"): - """Initialize thumbnail generator with fixed dimensions""" - self.output_dir = Path(output_dir) - self.output_dir.mkdir(parents=True, exist_ok=True) - self.THUMB_WIDTH = 300 - self.THUMB_HEIGHT = 424 # Roughly A4 proportion - - def download_pdf(self, url: str) -> bytes: - """Download PDF with proper headers""" - headers = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', - 'Accept': 'application/pdf,*/*' - } - - response = requests.get(url, headers=headers, timeout=30) - response.raise_for_status() - return response.content - - def create_thumbnail(self, pdf_content: bytes, paper_id: str) -> bool: - """Create fixed-size thumbnail from PDF content""" - try: - images = convert_from_bytes( - pdf_content, - first_page=1, - last_page=1, - size=(self.THUMB_WIDTH, self.THUMB_HEIGHT) - ) - - if not images: - return False - - # Create white background - background = Image.new('RGB', (self.THUMB_WIDTH, self.THUMB_HEIGHT), 'white') - - # Paste the PDF image onto background - thumb = images[0] - thumb.thumbnail((self.THUMB_WIDTH, self.THUMB_HEIGHT), Image.Resampling.LANCZOS) - - # Center the thumbnail - offset = ((self.THUMB_WIDTH - thumb.width) // 2, - (self.THUMB_HEIGHT - thumb.height) // 2) - background.paste(thumb, offset) - - # Save thumbnail - thumb_path = self.output_dir / f"{paper_id}.jpg" - background.save(thumb_path, "JPEG", quality=85, optimize=True) - logger.info(f"Created thumbnail for {paper_id}") - return True - - except Exception as e: - logger.error(f"Error creating thumbnail for {paper_id}: {str(e)}") - return False - -def format_entry(entry): - """Format a single YAML entry consistently""" - # Keep None values as None - they'll be rendered as empty strings in YAML - for key in ['project_page', 'code', 'video']: - if entry.get(key) == '': # Convert empty strings to None - entry[key] = None - - # Ensure abstract is a single line - if 'abstract' in entry: - entry['abstract'] = ' '.join(entry['abstract'].split()) - - # Ensure year is a string - if 'year' in entry: - entry['year'] = str(entry['year']) - - # Ensure tags are properly formatted - if 'tags' in entry: - entry['tags'] = sorted(tag.strip() for tag in entry['tags'] if tag) - - return entry - -class ArxivAddDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Add from arXiv") - self.setup_ui() - self.arxiv = ArxivIntegration() - self.thumbnail_generator = ThumbnailGenerator() - self.client = arxiv.Client() - - def setup_ui(self): - layout = QVBoxLayout(self) - - # URL input - input_layout = QHBoxLayout() - self.url_input = QLineEdit() - self.url_input.setPlaceholderText("Enter arXiv URL (e.g., https://arxiv.org/abs/2412.21206)") - self.add_button = QPushButton("Add Paper") - self.add_button.clicked.connect(self.add_paper) - input_layout.addWidget(self.url_input) - input_layout.addWidget(self.add_button) - layout.addLayout(input_layout) - - # Help text - help_text = QLabel("You can paste either:\n- Full arXiv URL (https://arxiv.org/abs/2412.21206)\n- arXiv ID (2412.21206)") - help_text.setStyleSheet("color: gray;") - layout.addWidget(help_text) - - # Status label - self.status_label = QLabel("") - layout.addWidget(self.status_label) - - def generate_thumbnail(self, entry): - """Generate thumbnail for the newly added paper""" - if not entry.get('paper'): - logger.warning(f"No PDF URL for {entry['id']}") - return False - - try: - self.status_label.setText("Generating thumbnail...") - QApplication.processEvents() # Update UI - - pdf_content = self.thumbnail_generator.download_pdf(entry['paper']) - success = self.thumbnail_generator.create_thumbnail(pdf_content, entry['id']) - - if success: - self.status_label.setText("Thumbnail generated successfully") - else: - self.status_label.setText("Failed to generate thumbnail") - - return success - - except Exception as e: - logger.error(f"Error generating thumbnail: {str(e)}") - self.status_label.setText(f"Error generating thumbnail: {str(e)}") - return False - - def add_paper(self): - url_or_id = self.url_input.text().strip() - if not url_or_id: - self.status_label.setText("Please enter an arXiv URL or ID") - return - - self.status_label.setText("Fetching paper information...") - self.add_button.setEnabled(False) - - try: - entry = self.arxiv.get_paper(url_or_id) - - if entry: - # Format the entry before adding - entry = format_entry(entry) - - # Ask for confirmation - msg = QMessageBox() - msg.setIcon(QMessageBox.Icon.Question) - msg.setText(f"Found paper:\n\n{entry['title']}\n\nAdd this paper?") - msg.setWindowTitle("Confirm Paper") - msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - - if msg.exec() == QMessageBox.StandardButton.Yes: - if self.arxiv.append_to_yaml(entry): - # Generate thumbnail after successful paper addition - thumbnail_success = self.generate_thumbnail(entry) - - if thumbnail_success: - QMessageBox.information(self, "Success", - "Paper added successfully and thumbnail generated!") - else: - QMessageBox.warning(self, "Partial Success", - "Paper added but failed to generate thumbnail.") - - self.accept() # Close dialog - else: - QMessageBox.warning(self, "Error", - "Failed to add paper. It might already exist.") - else: - self.status_label.setText("Could not find paper with given ID") - - except Exception as e: - self.status_label.setText(f"Error: {str(e)}") - finally: - self.add_button.setEnabled(True) - - -class TagButton(QPushButton): - def __init__(self, text, active=False): - super().__init__(text) - self.active = active - self.setCheckable(True) - self.setChecked(active) - self.setStyleSheet(""" - QPushButton { - padding: 5px 10px; - border-radius: 15px; - border: 1px solid #ccc; - background-color: white; - margin: 2px; - } - QPushButton:checked { - background-color: #007bff; - color: white; - border: none; - } - """) - self.setMinimumHeight(30) - - -class URLWidget(QWidget): - def __init__(self, label_text): - super().__init__() - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self.label = QLabel(label_text) - self.label.setMinimumWidth(100) - self.url_input = QLineEdit() - self.open_button = QPushButton("Open") - - def set_text(value): - self.url_input.setText("" if value is None else str(value)) - - self.set_text = set_text - - layout.addWidget(self.label) - layout.addWidget(self.url_input) - layout.addWidget(self.open_button) +from src.components.widgets import TagButton, URLWidget +from src.components.dialogs import ArxivAddDialog class YAMLEditor(QMainWindow): def __init__(self): @@ -259,12 +27,15 @@ def __init__(self): # Available tags self.available_tags = [ - "2DGS", "360 degree", "Acceleration", "Antialiasing", "Autonomous Driving", "Avatar", "Classic Work", "Code", "Compression", - "Deblurring", "Densification","Diffusion", "Distributed", "Dynamic", "Editing", "Event Camera", "Feed-Forward", "GAN", "Inpainting", - "In the Wild", "Language Embedding", "Large-Scale", "Lidar", "Medicine", "Meshing", "Misc", "Monocular", - "Perspective-correct", "Object Detection", "Optimization", "Physics", "Point Cloud", "Poses", "Project", - "Ray Tracing", "Rendering", "Relight", "Review", "Robotics", "Segmentation", "SLAM", "Sparse", - "Stereo", "Style Transfer", "Texturing","Transformer", "Uncertainty", "Video", "Virtual Reality", "World Generation" + "2DGS", "360 degree", "Acceleration", "Antialiasing", "Autonomous Driving", + "Avatar", "Classic Work", "Code", "Compression", "Deblurring", "Densification", + "Diffusion", "Distributed", "Dynamic", "Editing", "Event Camera", "Feed-Forward", + "GAN", "Inpainting", "In the Wild", "Language Embedding", "Large-Scale", "Lidar", + "Medicine", "Meshing", "Misc", "Monocular", "Perspective-correct", "Object Detection", + "Optimization", "Physics", "Point Cloud", "Poses", "Project", "Ray Tracing", + "Rendering", "Relight", "Review", "Robotics", "Segmentation", "SLAM", "Sparse", + "Stereo", "Style Transfer", "Texturing", "Transformer", "Uncertainty", "Video", + "Virtual Reality", "World Generation" ] # Load YAML data @@ -285,25 +56,20 @@ def load_yaml(self): sys.exit(1) def setup_status_bar(self): - """Setup status bar for save feedback""" self.statusBar().showMessage("") self.save_indicator = QLabel("") self.statusBar().addPermanentWidget(self.save_indicator) def show_save_feedback(self, success=True): - """Show save feedback in status bar""" if success: self.save_indicator.setText("✓ Changes saved") self.save_indicator.setStyleSheet("color: #4CAF50; font-weight: bold;") else: self.save_indicator.setText("⚠ Save failed") self.save_indicator.setStyleSheet("color: #f44336; font-weight: bold;") - - # Clear the indicator after 1.5 seconds - QTimer.singleShot(1500, lambda: self.clear_save_indicator()) + QTimer.singleShot(1500, self.clear_save_indicator) def clear_save_indicator(self): - """Clear the save indicator""" self.save_indicator.setText("") self.save_indicator.setStyleSheet("") @@ -470,12 +236,10 @@ def auto_save(self): return False def handle_url_change(self): - """Handle URL changes and update tags accordingly""" self.update_automatic_tags() self.auto_save() def get_entry_state(self, entry): - """Get the current state of the entry for change comparison""" return { 'basic_fields': {field: entry.get(field, '') for field in self.fields.keys()}, 'url_fields': {field: entry.get(field, '') for field in self.url_widgets.keys()}, @@ -483,55 +247,43 @@ def get_entry_state(self, entry): } def update_tags(self): - """Update the current tags list when tags are toggled""" current_tags = [tag for tag, btn in self.tag_buttons.items() if btn.isChecked()] self.current_tags_list.clear() self.current_tags_list.addItems(sorted(current_tags)) self.auto_save() def update_automatic_tags(self): - """Update automatic tags based on URL fields""" current_tags = set(tag for tag, btn in self.tag_buttons.items() if btn.isChecked()) - # Define automatic tags and their corresponding URL fields auto_tag_mapping = { 'Project': 'project_page', 'Code': 'code', 'Video': 'video' } - # Update automatic tags based on URL presence for tag, field in auto_tag_mapping.items(): if self.url_widgets[field].url_input.text().strip(): current_tags.add(tag) else: current_tags.discard(tag) - # Update tag buttons for tag, btn in self.tag_buttons.items(): btn.setChecked(tag in current_tags) - # Update tags list self.current_tags_list.clear() self.current_tags_list.addItems(sorted(current_tags)) def clear_search_results(self): - """Clear search results and status bar message""" self.search_results = [] self.statusBar().clearMessage() self.search_input.clear() def show_current_entry(self): - """Display the current entry""" entry = self.data[self.current_index] - # Store original state self.original_entry_state = self.get_entry_state(entry) - - # Update entry counter self.entry_counter.setText(f"Entry {self.current_index + 1} of {len(self.data)}") - # Update fields without triggering auto-save for field, widget in self.fields.items(): value = entry.get(field, '') if isinstance(widget, QLineEdit): @@ -543,39 +295,32 @@ def show_current_entry(self): widget.setText(str(value) if value is not None else '') widget.blockSignals(False) - # Update URL fields without triggering auto-save for field, widget in self.url_widgets.items(): value = entry.get(field, '') widget.url_input.blockSignals(True) widget.url_input.setText(str(value) if value is not None else '') widget.url_input.blockSignals(False) - # Load existing tags current_tags = set(entry.get('tags', [])) for tag, btn in self.tag_buttons.items(): btn.blockSignals(True) btn.setChecked(tag in current_tags) btn.blockSignals(False) - # Update current tags list self.current_tags_list.clear() self.current_tags_list.addItems(sorted(current_tags)) def search_entry(self): - """Search for entries by title, authors, or tags with navigation between results""" search_term = self.search_input.text().lower() if not search_term: return - # Find all matching indices self.search_results = [] for i, entry in enumerate(self.data): - # Safely get values, converting None to empty string title = entry.get('title', '') or '' authors = entry.get('authors', '') or '' tags = entry.get('tags', []) - # Case insensitive search in title, authors, and tags title_match = search_term in title.lower() authors_match = search_term in authors.lower() tags_match = any(search_term in (tag or '').lower() for tag in tags) @@ -587,18 +332,14 @@ def search_entry(self): QMessageBox.information(self, "Search Results", "No matches found.") return - # If current index is in results, move to next result if self.current_index in self.search_results: current_pos = self.search_results.index(self.current_index) next_pos = (current_pos + 1) % len(self.search_results) self.current_index = self.search_results[next_pos] else: - # Start with first result self.current_index = self.search_results[0] self.show_current_entry() - - # Show result count in status bar current_result = self.search_results.index(self.current_index) + 1 self.statusBar().showMessage( f"Showing result {current_result} of {len(self.search_results)}. " @@ -606,13 +347,11 @@ def search_entry(self): ) def open_url(self, field): - """Open URL in browser""" url = self.url_widgets[field].url_input.text() if url: webbrowser.open(url) def go_to_page(self): - """Navigate to specific page number""" try: page = int(self.page_input.text()) if 1 <= page <= len(self.data): @@ -624,18 +363,21 @@ def go_to_page(self): pass def prev_entry(self): - """Go to previous entry""" if self.current_index > 0: self.current_index -= 1 self.clear_search_results() self.show_current_entry() + def next_entry(self): + if self.current_index < len(self.data) - 1: + self.current_index += 1 + self.clear_search_results() + self.show_current_entry() + def delete_current_entry(self): - """Delete the current entry and its thumbnail after confirmation""" entry = self.data[self.current_index] title = entry.get('title', 'this entry') - # Show confirmation dialog msg = QMessageBox() msg.setIcon(QMessageBox.Icon.Question) msg.setText(f"Are you sure you want to delete '{title}'?") @@ -658,41 +400,26 @@ def delete_current_entry(self): yaml.dump(self.data, file, sort_keys=False, allow_unicode=True) self.show_save_feedback(True) - # Update current_index if necessary if self.current_index >= len(self.data): self.current_index = len(self.data) - 1 - # Show the new current entry if self.data: self.show_current_entry() else: - # No more entries self.close() except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save changes: {str(e)}") - - def next_entry(self): - """Go to next entry""" - if self.current_index < len(self.data) - 1: - self.current_index += 1 - self.clear_search_results() - self.show_current_entry() - + def add_arxiv_button(self): - """Add arXiv button to navigation layout""" self.arxiv_button = QPushButton("Add from arXiv") self.arxiv_button.clicked.connect(self.show_arxiv_dialog) - # Add to existing nav_layout (modify setup_ui method) self.nav_layout.addWidget(self.arxiv_button) def show_arxiv_dialog(self): - """Show arXiv paper addition dialog""" dialog = ArxivAddDialog(self) - if dialog.exec() == QDialog.DialogCode.Accepted: - # Reload YAML data + if dialog.exec() == QDialog.accepted: self.load_yaml() - # Go to last entry self.current_index = len(self.data) - 1 self.show_current_entry()