├── screenshot.png ├── .gitignore ├── pixi.toml ├── LICENSE ├── workflow.svg ├── README.md ├── align_images.py ├── web_server.py ├── propagate_markers.py ├── auto_markers.py ├── review_markers.py ├── select_markers.py └── static └── index.html /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cranmer/claude-code-first-attempt/main/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | *.egg-info/ 8 | .eggs/ 9 | dist/ 10 | build/ 11 | 12 | # Pixi 13 | .pixi/ 14 | 15 | # Images - don't commit photos 16 | originals/ 17 | processed/ 18 | 19 | # Environment 20 | .env 21 | .venv/ 22 | env/ 23 | venv/ 24 | 25 | # IDE 26 | .idea/ 27 | .vscode/ 28 | *.swp 29 | *.swo 30 | .DS_Store 31 | 32 | # Marker data (user-specific) 33 | markers.json 34 | markers_auto.json 35 | markers.reviewed.json 36 | markers_auto.reviewed.json 37 | config.json 38 | -------------------------------------------------------------------------------- /pixi.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "image-alignment" 3 | version = "0.1.0" 4 | description = "Tool for aligning landscape photos using marker-based registration" 5 | channels = ["conda-forge"] 6 | platforms = ["osx-arm64", "osx-64", "linux-64"] 7 | 8 | [tasks] 9 | # Manual workflow (OpenCV GUI) 10 | select = "python select_markers.py" 11 | propagate = "python propagate_markers.py" 12 | review = "python review_markers.py" 13 | align = "python align_images.py" 14 | 15 | # Web-based workflow (runs on port 8080) 16 | web = "python web_server.py" 17 | web-debug = "python web_server.py --debug" 18 | 19 | # Fully automated workflow (uses markers_auto.json) 20 | auto-detect = "python auto_markers.py" 21 | auto-propagate = "python propagate_markers.py --input markers_auto.json --output markers_auto.json" 22 | auto-review = "python review_markers.py --markers markers_auto.json" 23 | auto-align = "python align_images.py --markers markers_auto.json" 24 | 25 | [dependencies] 26 | python = ">=3.10" 27 | opencv = ">=4.8" 28 | numpy = ">=1.24" 29 | flask = ">=3.0" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Kyle Cranmer 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /workflow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Image Alignment Workflow 13 | 14 | 15 | 16 | 1. Add Photos 17 | Place images in 18 | originals/ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 2. Select Markers 26 | Mark 1-3 reference 27 | images manually 28 | 29 | 30 | 31 | 32 | 33 | 34 | 3. Propagate 35 | Auto-find markers 36 | in other images 37 | 38 | 39 | 40 | 41 | 42 | 43 | 4. Review 44 | Adjust, accept, or 45 | reject markers 46 | 47 | 48 | 49 | 50 | 51 | 52 | 5. Align 53 | Transform images 54 | to match reference 55 | 56 | 57 | 58 | 59 | 60 | 61 | Output 62 | Aligned images in 63 | processed/ 64 | 65 | 66 | 67 | Commands: 68 | pixi run select 69 | ← Manual marking 70 | pixi run propagate 71 | ← Auto-detect 72 | pixi run review 73 | ← Curate results 74 | pixi run align 75 | ← Generate output 76 | 77 | 78 | 79 | 80 | Files: 81 | originals/ 82 | 83 | markers.json 84 | 85 | markers.reviewed.json 86 | 87 | processed/ 88 | 89 | 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Alignment Tool 2 | 3 | Align landscape photos taken from roughly the same location by adjusting scale, orientation, and position using marker-based registration. 4 | 5 | ![Web UI Screenshot](screenshot.png) 6 | 7 | ![Workflow](workflow.svg) 8 | 9 | ## Installation 10 | 11 | ``` 12 | pixi install 13 | ``` 14 | 15 | ## Workflows 16 | 17 | There are three ways to use this tool: 18 | - **Web UI** (recommended): Browser-based interface for marker selection, review, and comparison 19 | - **Semi-automated**: OpenCV GUI for manual marker selection with automatic propagation 20 | - **Fully automated**: Automatic marker detection and propagation 21 | 22 | --- 23 | 24 | ## Web UI (Recommended) 25 | 26 | The web interface provides a modern browser-based experience for all marker operations: 27 | 28 | ``` 29 | pixi run web 30 | ``` 31 | 32 | Then open http://127.0.0.1:8080 in your browser. 33 | 34 | **Features:** 35 | - **Select Markers**: Click to place markers, with previous image shown as reference 36 | - **Review Markers**: Accept or reject propagated markers 37 | - **Compare Results**: View original and aligned images side-by-side 38 | 39 | **Keyboard shortcuts:** `N` next, `P` prev, `D` undo, `Del` delete selected, `C` clear all, `S` save 40 | 41 | --- 42 | 43 | ## Semi-Automated Workflow (OpenCV GUI) 44 | 45 | Uses `markers.json` for marker storage. 46 | 47 | ### 1. Add Original Photos 48 | 49 | Place your photos in the `originals/` directory. 50 | 51 | ### 2. Manually Mark Reference Images 52 | 53 | Select corresponding landmark points on 1-3 reference images: 54 | 55 | ``` 56 | pixi run select 57 | ``` 58 | 59 | **Controls:** 60 | - Left click: Add marker point 61 | - `n`: Next image 62 | - `p`: Previous image 63 | - `d`: Delete last point (or click "Undo Last" button) 64 | - `c`: Clear all points (or click "Clear All" button) 65 | - `s`: Save progress 66 | - `q`: Save and quit 67 | 68 | **How many markers?** Aim for **3-4 markers** per image: 69 | - 3 markers minimum: Allows affine transform (handles scale, rotation, translation) 70 | - 4+ markers: Enables homography/perspective correction (better for slight viewpoint changes) 71 | 72 | **Marker placement tips:** 73 | - Choose sharp, well-defined points (corners, tree tips, rock edges) 74 | - Ensure points are visible in all photos across seasons 75 | - Spread markers across the image (not clustered in one area) 76 | - Good examples: dead tree tops, building corners, distinctive rocks, fence posts 77 | 78 | ### 3. Propagate Markers to Remaining Images 79 | 80 | Automatically find corresponding markers in unmarked images: 81 | 82 | ``` 83 | pixi run propagate 84 | ``` 85 | 86 | **Options:** 87 | ``` 88 | pixi run propagate -- --threshold 0.4 # Lower threshold (accept more matches) 89 | pixi run propagate -- --threshold 0.7 # Higher threshold (stricter matching) 90 | pixi run propagate -- --dry-run # Preview without saving 91 | ``` 92 | 93 | ### 4. Review and Curate Markers 94 | 95 | Review the automatically found markers, adjust positions, and accept or reject: 96 | 97 | ``` 98 | pixi run review 99 | ``` 100 | 101 | **Controls:** 102 | - Click + drag: Move existing points 103 | - Click empty area: Add new point 104 | - `a`: Accept markers 105 | - `r`: Reject markers (removes image from alignment) 106 | - `d`: Delete last point 107 | - `n`: Next image 108 | - `p`: Previous image 109 | - `q`: Save and quit 110 | 111 | ### 5. Align Images 112 | 113 | Transform all images to align with the reference: 114 | 115 | ``` 116 | pixi run align 117 | ``` 118 | 119 | **Options:** 120 | ``` 121 | pixi run align -- --reference "IMG_3230 Medium.jpeg" # Specify reference image 122 | ``` 123 | 124 | Aligned images are saved to the `processed/` directory. 125 | 126 | --- 127 | 128 | ## Fully Automated Workflow 129 | 130 | Uses `markers_auto.json` for marker storage, keeping it separate from manual markers. 131 | 132 | ### 1. Auto-Detect Markers in Reference Images 133 | 134 | Automatically detect good marker points using feature detection: 135 | 136 | ``` 137 | pixi run auto-detect -- --reference "IMG_3230 Medium.jpeg" --visualize 138 | ``` 139 | 140 | **Options:** 141 | ``` 142 | --reference IMAGE [IMAGE ...] # Reference image(s) to detect markers in (required) 143 | --num-markers 4 # Number of markers to detect (default: 4) 144 | --method hybrid # Detection method: orb, corners, or hybrid (default: hybrid) 145 | --visualize # Show detected markers in a window 146 | ``` 147 | 148 | ### 2. Propagate to Remaining Images 149 | 150 | ``` 151 | pixi run auto-propagate 152 | ``` 153 | 154 | ### 3. Review and Curate 155 | 156 | ``` 157 | pixi run auto-review 158 | ``` 159 | 160 | ### 4. Align Images 161 | 162 | ``` 163 | pixi run auto-align 164 | ``` 165 | 166 | --- 167 | 168 | ## Comparing Workflows 169 | 170 | You can run both workflows and compare results since they use different marker files: 171 | 172 | | Workflow | Marker File | Commands | 173 | |----------|-------------|----------| 174 | | Semi-automated | `markers.json` | `select`, `propagate`, `review`, `align` | 175 | | Fully automated | `markers_auto.json` | `auto-detect`, `auto-propagate`, `auto-review`, `auto-align` | 176 | 177 | --- 178 | 179 | ## File Structure 180 | 181 | ``` 182 | . 183 | ├── originals/ # Original photos 184 | ├── processed/ # Aligned output photos 185 | ├── markers.json # Manual workflow markers 186 | ├── markers_auto.json # Automated workflow markers 187 | ├── markers.reviewed.json # Review status (manual workflow) 188 | ├── markers_auto.reviewed.json # Review status (auto workflow) 189 | ├── web_server.py # Web UI server 190 | ├── static/ # Web UI frontend 191 | ├── select_markers.py # OpenCV marker selection GUI 192 | ├── auto_markers.py # Automatic marker detection 193 | ├── propagate_markers.py # Marker propagation to other images 194 | ├── review_markers.py # Review and curate markers 195 | ├── align_images.py # Image alignment/warping 196 | └── pixi.toml # Project configuration 197 | ``` 198 | 199 | ## How It Works 200 | 201 | 1. **Marker Selection**: You identify corresponding points (landmarks) across images 202 | 2. **Propagation**: Template matching finds the same landmarks in other images 203 | 3. **Alignment**: OpenCV computes a homography (perspective transform) or affine transform from the marker correspondences and warps each image to match the reference 204 | -------------------------------------------------------------------------------- /align_images.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Image alignment tool using marker-based registration. 4 | Aligns images by adjusting scale, orientation, and position based on corresponding points. 5 | """ 6 | 7 | import argparse 8 | import json 9 | from pathlib import Path 10 | 11 | import cv2 12 | import numpy as np 13 | 14 | CONFIG_FILE = Path("config.json") 15 | 16 | 17 | def load_config() -> dict: 18 | """Load configuration from config.json if it exists.""" 19 | if CONFIG_FILE.exists(): 20 | with open(CONFIG_FILE) as f: 21 | return json.load(f) 22 | return {} 23 | 24 | 25 | def load_markers(markers_file: Path) -> dict: 26 | """Load marker coordinates from a JSON file, converting old format if needed.""" 27 | with open(markers_file) as f: 28 | loaded = json.load(f) 29 | 30 | # Convert old format to new format if needed 31 | converted = {} 32 | for img_name, points in loaded.items(): 33 | if isinstance(points, list): 34 | # Old format: [[x,y], [x,y], ...] 35 | converted[img_name] = { 36 | str(i + 1): pt for i, pt in enumerate(points) 37 | } 38 | else: 39 | # New format: {"1": [x,y], "2": [x,y], ...} 40 | converted[img_name] = points 41 | return converted 42 | 43 | 44 | def markers_to_array(markers_dict: dict, labels: list[str]) -> np.ndarray: 45 | """Convert markers dict to numpy array, using only specified labels in order.""" 46 | points = [] 47 | for label in labels: 48 | if label in markers_dict: 49 | points.append(markers_dict[label]) 50 | return np.array(points) 51 | 52 | 53 | def align_image( 54 | source_img: np.ndarray, 55 | source_points: np.ndarray, 56 | reference_points: np.ndarray, 57 | output_size: tuple[int, int] | None = None, 58 | ) -> np.ndarray: 59 | """ 60 | Align source image to reference using corresponding marker points. 61 | 62 | Args: 63 | source_img: Image to be aligned 64 | source_points: Marker coordinates in source image (Nx2 array) 65 | reference_points: Corresponding marker coordinates in reference (Nx2 array) 66 | output_size: Optional (width, height) for output image 67 | 68 | Returns: 69 | Aligned image 70 | """ 71 | source_points = np.float32(source_points) 72 | reference_points = np.float32(reference_points) 73 | 74 | if len(source_points) < 4: 75 | # Use affine transform for 3 points (handles scale, rotation, translation) 76 | matrix = cv2.getAffineTransform(source_points[:3], reference_points[:3]) 77 | if output_size is None: 78 | output_size = (source_img.shape[1], source_img.shape[0]) 79 | aligned = cv2.warpAffine(source_img, matrix, output_size) 80 | else: 81 | # Use homography for 4+ points (handles perspective) 82 | matrix, mask = cv2.findHomography(source_points, reference_points, cv2.RANSAC, 5.0) 83 | if output_size is None: 84 | output_size = (source_img.shape[1], source_img.shape[0]) 85 | aligned = cv2.warpPerspective(source_img, matrix, output_size) 86 | 87 | return aligned 88 | 89 | 90 | def process_images( 91 | originals_dir: Path, 92 | processed_dir: Path, 93 | markers_file: Path, 94 | reference_name: str | None = None, 95 | ) -> None: 96 | """ 97 | Process all images in originals directory using marker data. 98 | 99 | Args: 100 | originals_dir: Directory containing original images 101 | processed_dir: Directory for aligned output images 102 | markers_file: JSON file with marker coordinates 103 | reference_name: Name of reference image (first image used if not specified) 104 | """ 105 | markers = load_markers(markers_file) 106 | 107 | # Get list of images from markers file 108 | image_names = list(markers.keys()) 109 | if not image_names: 110 | print("No images found in markers file") 111 | return 112 | 113 | # Use first image as reference if not specified 114 | if reference_name is None: 115 | reference_name = image_names[0] 116 | 117 | if reference_name not in markers: 118 | print(f"Reference image '{reference_name}' not found in markers file") 119 | return 120 | 121 | # Get common labels across all images (labels that exist in reference) 122 | reference_labels = sorted(markers[reference_name].keys(), key=int) 123 | reference_points = markers_to_array(markers[reference_name], reference_labels) 124 | 125 | print(f"Reference image: {reference_name}") 126 | print(f"Using marker labels: {reference_labels}") 127 | 128 | # Load reference image to get output size 129 | ref_path = originals_dir / reference_name 130 | if ref_path.exists(): 131 | ref_img = cv2.imread(str(ref_path)) 132 | output_size = (ref_img.shape[1], ref_img.shape[0]) 133 | # Copy reference image to processed directory 134 | cv2.imwrite(str(processed_dir / reference_name), ref_img) 135 | print(f"Copied reference image: {reference_name}") 136 | else: 137 | output_size = None 138 | print(f"Warning: Reference image file not found: {ref_path}") 139 | 140 | # Process each image 141 | for img_name in image_names: 142 | if img_name == reference_name: 143 | continue 144 | 145 | img_path = originals_dir / img_name 146 | if not img_path.exists(): 147 | print(f"Warning: Image not found: {img_path}") 148 | continue 149 | 150 | # Get common labels between this image and reference 151 | source_markers = markers[img_name] 152 | common_labels = [l for l in reference_labels if l in source_markers] 153 | 154 | if len(common_labels) < 3: 155 | print(f"Warning: {img_name} has only {len(common_labels)} common markers with reference (need 3+)") 156 | continue 157 | 158 | source_img = cv2.imread(str(img_path)) 159 | source_points = markers_to_array(source_markers, common_labels) 160 | ref_points_subset = markers_to_array(markers[reference_name], common_labels) 161 | 162 | aligned = align_image(source_img, source_points, ref_points_subset, output_size) 163 | 164 | output_path = processed_dir / img_name 165 | cv2.imwrite(str(output_path), aligned) 166 | print(f"Aligned: {img_name} -> {output_path} (using {len(common_labels)} markers)") 167 | 168 | 169 | def main(): 170 | config = load_config() 171 | 172 | parser = argparse.ArgumentParser( 173 | description="Align images using marker-based registration" 174 | ) 175 | parser.add_argument( 176 | "--originals", 177 | type=Path, 178 | default=Path(config.get("originals_dir", "originals")), 179 | help="Directory containing original images (default: originals)", 180 | ) 181 | parser.add_argument( 182 | "--processed", 183 | type=Path, 184 | default=Path(config.get("processed_dir", "processed")), 185 | help="Directory for aligned images (default: processed)", 186 | ) 187 | parser.add_argument( 188 | "--markers", 189 | type=Path, 190 | default=Path("markers.json"), 191 | help="JSON file with marker coordinates (default: markers.json)", 192 | ) 193 | parser.add_argument( 194 | "--reference", 195 | type=str, 196 | default=config.get("reference_image"), 197 | help="Name of reference image (default: from config.json or first image in markers file)", 198 | ) 199 | 200 | args = parser.parse_args() 201 | 202 | if not args.originals.exists(): 203 | print(f"Error: Originals directory not found: {args.originals}") 204 | return 1 205 | 206 | if not args.markers.exists(): 207 | print(f"Error: Markers file not found: {args.markers}") 208 | print("\nCreate a markers.json file with the following format:") 209 | print(json.dumps({ 210 | "image1.jpg": [[x1, y1], [x2, y2], [x3, y3]], 211 | "image2.jpg": [[x1, y1], [x2, y2], [x3, y3]], 212 | }, indent=2)) 213 | return 1 214 | 215 | args.processed.mkdir(parents=True, exist_ok=True) 216 | 217 | process_images(args.originals, args.processed, args.markers, args.reference) 218 | return 0 219 | 220 | 221 | if __name__ == "__main__": 222 | exit(main()) 223 | -------------------------------------------------------------------------------- /web_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Web server for marker selection and review. 4 | Provides REST API and serves the web interface. 5 | """ 6 | 7 | import json 8 | import subprocess 9 | import sys 10 | from pathlib import Path 11 | 12 | from flask import Flask, jsonify, request, send_file, send_from_directory 13 | 14 | app = Flask(__name__, static_folder="static") 15 | 16 | # Configuration 17 | CONFIG_FILE = Path("config.json") 18 | MARKERS_FILE = Path("markers.json") 19 | IMAGES_DIR = Path("originals") 20 | PROCESSED_DIR = Path("processed") 21 | 22 | 23 | def load_config() -> dict: 24 | """Load configuration from config.json if it exists.""" 25 | if CONFIG_FILE.exists(): 26 | with open(CONFIG_FILE) as f: 27 | return json.load(f) 28 | return {} 29 | 30 | 31 | def load_markers() -> dict: 32 | """Load markers from JSON file, converting old format if needed.""" 33 | if not MARKERS_FILE.exists(): 34 | return {} 35 | with open(MARKERS_FILE) as f: 36 | loaded = json.load(f) 37 | 38 | # Convert old format to new format if needed 39 | converted = {} 40 | for img_name, points in loaded.items(): 41 | if isinstance(points, list): 42 | converted[img_name] = {str(i + 1): pt for i, pt in enumerate(points)} 43 | else: 44 | converted[img_name] = points 45 | return converted 46 | 47 | 48 | def save_markers(markers: dict) -> None: 49 | """Save markers to JSON file.""" 50 | with open(MARKERS_FILE, "w") as f: 51 | json.dump(markers, f, indent=2) 52 | 53 | 54 | def get_image_files() -> list[str]: 55 | """Get sorted list of image files in the images directory.""" 56 | if not IMAGES_DIR.exists(): 57 | return [] 58 | extensions = [".jpg", ".jpeg", ".png", ".tiff", ".tif"] 59 | files = [ 60 | f.name 61 | for f in sorted(IMAGES_DIR.iterdir()) 62 | if f.suffix.lower() in extensions 63 | ] 64 | return files 65 | 66 | 67 | # API Routes 68 | 69 | 70 | @app.route("/") 71 | def index(): 72 | """Serve the main page.""" 73 | return send_from_directory("static", "index.html") 74 | 75 | 76 | @app.route("/api/config") 77 | def get_config(): 78 | """Get current configuration.""" 79 | config = load_config() 80 | return jsonify(config) 81 | 82 | 83 | @app.route("/api/images") 84 | def list_images(): 85 | """List all available images.""" 86 | images = get_image_files() 87 | markers = load_markers() 88 | 89 | # Add marker count for each image 90 | result = [] 91 | for img in images: 92 | result.append({ 93 | "name": img, 94 | "markers": len(markers.get(img, {})), 95 | }) 96 | return jsonify(result) 97 | 98 | 99 | @app.route("/api/images/") 100 | def get_image(filename): 101 | """Serve an original image file.""" 102 | return send_file(IMAGES_DIR / filename) 103 | 104 | 105 | @app.route("/api/processed/") 106 | def get_processed_image(filename): 107 | """Serve a processed/aligned image file.""" 108 | processed_path = PROCESSED_DIR / filename 109 | if processed_path.exists(): 110 | return send_file(processed_path) 111 | else: 112 | # Return 404 if processed image doesn't exist 113 | return jsonify({"error": "Processed image not found"}), 404 114 | 115 | 116 | @app.route("/api/processed") 117 | def list_processed_images(): 118 | """List all processed images.""" 119 | if not PROCESSED_DIR.exists(): 120 | return jsonify([]) 121 | extensions = [".jpg", ".jpeg", ".png", ".tiff", ".tif"] 122 | files = [ 123 | f.name 124 | for f in sorted(PROCESSED_DIR.iterdir()) 125 | if f.suffix.lower() in extensions 126 | ] 127 | return jsonify(files) 128 | 129 | 130 | @app.route("/api/markers") 131 | def get_all_markers(): 132 | """Get all markers.""" 133 | markers = load_markers() 134 | return jsonify(markers) 135 | 136 | 137 | @app.route("/api/markers/") 138 | def get_image_markers(image_name): 139 | """Get markers for a specific image.""" 140 | markers = load_markers() 141 | return jsonify(markers.get(image_name, {})) 142 | 143 | 144 | @app.route("/api/markers/", methods=["PUT"]) 145 | def update_image_markers(image_name): 146 | """Update markers for a specific image.""" 147 | markers = load_markers() 148 | markers[image_name] = request.json 149 | save_markers(markers) 150 | return jsonify({"status": "ok"}) 151 | 152 | 153 | @app.route("/api/markers//