{"id":8214,"date":"2026-09-04T22:27:09","date_gmt":"2026-09-04T22:27:09","guid":{"rendered":"https:\/\/robertjwallace.com\/?p=8214"},"modified":"2026-09-04T22:29:13","modified_gmt":"2026-09-04T22:29:13","slug":"wow-ai-coding-is-here","status":"publish","type":"post","link":"https:\/\/robertjwallace.com\/es\/wow-ai-coding-is-here\/","title":{"rendered":"Wow!  AI coding is here!"},"content":{"rendered":"<p class=\"wp-block-paragraph\">I am a photographer.  As a photographer I shoot my images in RAW format.  This gives me the greatest flexibility for post processing.  But I hate the RAW photo editors, such as Darktable and RawTherapee.  Don&#8217;t get me wrong, those are great tools, but I often just want to do some simple quick editing.  Those tools have complicated user interfaces.  So I wondered if I could use Claude Code to write a Raw Editor with a simple interface similar to the Samsung Galaxy photo editor that I use for editing phone images.<\/p>\n\n\n\n<!--more-->\n\n\n\n<h2 class=\"wp-block-heading\">Short answer is Yes!<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before I get into the actual steps (which took less that an hour) let me show you what I came up with.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the lighting page, after I have loaded a raw image. As you can see, the interface is very simple and clean.  I can adjust the light, color, details (sharpening, denoise), and transforms (rotates and cropping).<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" width=\"1024\" height=\"578\" data-src=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-44-21-1024x578.webp\" alt=\"\" class=\"wp-image-8220 lazyload\" data-srcset=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-44-21-1024x578.webp 1024w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-44-21-300x169.webp 300w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-44-21-768x434.webp 768w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-44-21-18x10.webp 18w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-44-21.webp 1360w\" data-sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" src=\"data:image\/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==\" style=\"--smush-placeholder-width: 1024px; --smush-placeholder-aspect-ratio: 1024\/578;\"><figcaption class=\"wp-element-caption\">Light adjustment tool<\/figcaption><\/figure>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" width=\"1024\" height=\"578\" data-src=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-46-04-1024x578.webp\" alt=\"\" class=\"wp-image-8222 lazyload\" data-srcset=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-46-04-1024x578.webp 1024w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-46-04-300x169.webp 300w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-46-04-768x434.webp 768w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-46-04-18x10.webp 18w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-46-04.webp 1360w\" data-sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" src=\"data:image\/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==\" style=\"--smush-placeholder-width: 1024px; --smush-placeholder-aspect-ratio: 1024\/578;\"><\/figure>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" width=\"1024\" height=\"578\" data-src=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-49-11-1024x578.webp\" alt=\"\" class=\"wp-image-8224 lazyload\" data-srcset=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-49-11-1024x578.webp 1024w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-49-11-300x169.webp 300w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-49-11-768x434.webp 768w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-49-11-18x10.webp 18w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-14-49-11.webp 1360w\" data-sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" src=\"data:image\/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==\" style=\"--smush-placeholder-width: 1024px; --smush-placeholder-aspect-ratio: 1024\/578;\"><figcaption class=\"wp-element-caption\">Curve tool<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The basic tool was created in less than an hour.  Tweaking and making adjustments has taken a couple of more hours.   Note that I did no coding or interface design.  Claude came up with all of that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Compare this interface to RawTherapy.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" width=\"1024\" height=\"578\" data-src=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-15-16-51-1024x578.webp\" alt=\"\" class=\"wp-image-8227 lazyload\" data-srcset=\"https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-15-16-51-1024x578.webp 1024w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-15-16-51-300x169.webp 300w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-15-16-51-768x434.webp 768w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-15-16-51-18x10.webp 18w, https:\/\/robertjwallace-images.s3.us-east-2.amazonaws.com\/wp-content\/uploads\/2026\/09\/Screenshot-from-2026-09-04-15-16-51.webp 1360w\" data-sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" src=\"data:image\/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==\" style=\"--smush-placeholder-width: 1024px; --smush-placeholder-aspect-ratio: 1024\/578;\"><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">The technical stuff<\/h2>\n\n\n\n<pre class=\"wp-block-code has-custom-css wp-custom-css-9dc26ee3\"><code>#!\/usr\/bin\/env python3\n\"\"\"Galaxy RAW Editor - a lightweight RAW photo editor for Linux styled after\nSamsung's Galaxy (One UI) Photo Editor.\"\"\"\n\nimport os\nimport sys\n\nimport cv2\nimport numpy as np\nimport rawpy\nfrom PIL import Image\nfrom PySide6.QtCore import QPointF, QRectF, Qt, QThread, QTimer, Signal\nfrom PySide6.QtGui import QAction, QBrush, QColor, QCursor, QImage, QPainter, QPainterPath, QPen, QPixmap\nfrom PySide6.QtWidgets import (\n    QApplication,\n    QButtonGroup,\n    QFileDialog,\n    QGraphicsPathItem,\n    QGraphicsPixmapItem,\n    QGraphicsRectItem,\n    QGraphicsScene,\n    QGraphicsView,\n    QHBoxLayout,\n    QLabel,\n    QMainWindow,\n    QMessageBox,\n    QPushButton,\n    QSizePolicy,\n    QSlider,\n    QStackedWidget,\n    QVBoxLayout,\n    QWidget,\n)\n\nAPP_NAME = \"Galaxy RAW Editor\"\nRAW_EXTS = {\".orf\", \".cr2\", \".nef\", \".arw\", \".dng\", \".raf\", \".rw2\", \".pef\", \".srw\"}\nSTANDARD_EXTS = {\".jpg\", \".jpeg\", \".png\", \".bmp\", \".tif\", \".tiff\"}\nMAX_PREVIEW_DIM = 1600\n\ndef _build_open_filter():\n    exts = sorted(RAW_EXTS | STANDARD_EXTS)\n    patterns = &#91;]\n    for ext in exts:\n        patterns.append(f\"*{ext.lower()}\")\n        patterns.append(f\"*{ext.upper()}\")\n    return \"Images (\" + \" \".join(patterns) + \");;All Files (*)\"\n\n\nOPEN_FILTER = _build_open_filter()\n\n# --------------------------------------------------------------------------\n# Dark One UI-inspired stylesheet\n# --------------------------------------------------------------------------\nSTYLE_SHEET = \"\"\"\nQMainWindow, QWidget {\n    background-color: #141414;\n    color: #e8e8e8;\n    font-family: 'Noto Sans', 'Ubuntu', 'Segoe UI', sans-serif;\n    font-size: 13px;\n}\nQLabel#fileLabel {\n    color: #9c9c9c;\n    font-size: 12px;\n}\nQLabel#sliderTitle {\n    color: #e8e8e8;\n    font-size: 13px;\n    font-weight: 600;\n}\nQLabel#sliderValue {\n    color: #0a84ff;\n    font-size: 13px;\n    font-weight: 600;\n    min-width: 34px;\n}\nQPushButton {\n    background-color: #232323;\n    border: none;\n    border-radius: 10px;\n    padding: 8px 16px;\n    color: #e8e8e8;\n}\nQPushButton:hover { background-color: #2e2e2e; }\nQPushButton:pressed { background-color: #1a1a1a; }\nQPushButton:disabled { color: #5a5a5a; background-color: #1a1a1a; }\n\nQPushButton#primaryButton {\n    background-color: #0a84ff;\n    color: white;\n    font-weight: 600;\n    border-radius: 14px;\n    padding: 8px 22px;\n}\nQPushButton#primaryButton:hover { background-color: #2f95ff; }\nQPushButton#primaryButton:disabled { background-color: #23405f; color: #7a8ba0; }\n\nQPushButton#navButton {\n    background: transparent;\n    border: none;\n    border-radius: 0px;\n    padding: 12px 6px;\n    color: #8b8b8b;\n    font-weight: 600;\n    font-size: 13px;\n}\nQPushButton#navButton:checked {\n    color: #0a84ff;\n    border-top: 2px solid #0a84ff;\n}\nQPushButton#navButton:hover { color: #d0d0d0; }\n\nQPushButton#chipButton {\n    background-color: #1e1e1e;\n    border-radius: 15px;\n    padding: 6px 16px;\n    font-size: 12px;\n    color: #cfcfcf;\n}\nQPushButton#chipButton:checked {\n    background-color: #0a84ff;\n    color: white;\n}\n\nQPushButton#resetButton {\n    background-color: transparent;\n    border-radius: 14px;\n    padding: 4px 10px;\n    color: #0a84ff;\n    font-size: 12px;\n    font-weight: 600;\n}\nQPushButton#resetButton:hover { background-color: #1e2b3a; }\n\nQSlider::groove:horizontal {\n    height: 4px;\n    background: #3a3a3a;\n    border-radius: 2px;\n}\nQSlider::sub-page:horizontal {\n    background: #0a84ff;\n    border-radius: 2px;\n}\nQSlider::add-page:horizontal {\n    background: #3a3a3a;\n    border-radius: 2px;\n}\nQSlider::handle:horizontal {\n    background: #ffffff;\n    width: 18px;\n    height: 18px;\n    margin: -8px 0;\n    border-radius: 9px;\n}\n\nQGraphicsView {\n    background-color: #0a0a0a;\n    border: none;\n}\n\n#topBar, #sliderArea, #bottomNav {\n    background-color: #1a1a1a;\n}\n#bottomNav { border-top: 1px solid #262626; }\n#sliderArea { border-top: 1px solid #222222; }\n\"\"\"\n\n\n# --------------------------------------------------------------------------\n# Image processing engine\n# --------------------------------------------------------------------------\nclass ImageEngine:\n    \"\"\"Loads images\/RAW files and applies non-destructive adjustments.\"\"\"\n\n    @staticmethod\n    def load_image(path):\n        ext = os.path.splitext(path)&#91;1].lower()\n        if ext in RAW_EXTS:\n            with rawpy.imread(path) as raw:\n                rgb16 = raw.postprocess(\n                    use_camera_wb=True,\n                    no_auto_bright=False,\n                    output_bps=16,\n                )\n            arr = rgb16.astype(np.float32) \/ 65535.0\n        else:\n            pil_img = Image.open(path)\n            pil_img = pil_img.convert(\"RGB\")\n            arr = np.asarray(pil_img).astype(np.float32) \/ 255.0\n        return np.ascontiguousarray(arr)\n\n    @staticmethod\n    def make_preview(full, max_dim=MAX_PREVIEW_DIM):\n        h, w = full.shape&#91;:2]\n        scale = min(1.0, max_dim \/ max(h, w))\n        if scale &lt; 1.0:\n            new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale))\n            return cv2.resize(full, (new_w, new_h), interpolation=cv2.INTER_AREA)\n        return full.copy()\n\n    # -- individual adjustment operators (float32 RGB, 0..1) --------------\n    @staticmethod\n    def _exposure(img, val):\n        if val == 0:\n            return img\n        stops = (val \/ 100.0) * 2.0\n        return img * (2.0 ** stops)\n\n    @staticmethod\n    def _brightness(img, val):\n        if val == 0:\n            return img\n        return img + (val \/ 100.0) * 0.5\n\n    @staticmethod\n    def _contrast(img, val):\n        if val == 0:\n            return img\n        factor = 1.0 + (val \/ 100.0)\n        return (img - 0.5) * factor + 0.5\n\n    @staticmethod\n    def _highlights_shadows(img, highlights, shadows):\n        if highlights == 0 and shadows == 0:\n            return img\n        luminance = (img&#91;..., 0] * 0.299 + img&#91;..., 1] * 0.587 + img&#91;..., 2] * 0.114)\n        luminance = luminance&#91;..., None]\n        out = img\n        if highlights != 0:\n            amt = highlights \/ 100.0\n            mask = np.clip(luminance * 2.0 - 1.0, 0.0, 1.0)\n            out = out - amt * mask * out * 0.6\n        if shadows != 0:\n            amt = shadows \/ 100.0\n            mask = np.clip(1.0 - luminance * 2.0, 0.0, 1.0)\n            out = out + amt * mask * (1.0 - out) * 0.6\n        return out\n\n    @staticmethod\n    def _temperature_tint(img, temp, tint):\n        if temp == 0 and tint == 0:\n            return img\n        out = img.copy()\n        t = (temp \/ 100.0) * 0.25\n        ti = (tint \/ 100.0) * 0.25\n        out&#91;..., 0] = out&#91;..., 0] + t\n        out&#91;..., 2] = out&#91;..., 2] - t\n        out&#91;..., 1] = out&#91;..., 1] + ti\n        return out\n\n    @staticmethod\n    def _saturation_vibrance(img, sat, vib):\n        if sat == 0 and vib == 0:\n            return img\n        clipped = np.clip(img, 0.0, 1.0).astype(np.float32)\n        hsv = cv2.cvtColor(clipped, cv2.COLOR_RGB2HSV)\n        s = hsv&#91;..., 1]\n        if sat != 0:\n            s = s * (1.0 + sat \/ 100.0)\n        if vib != 0:\n            boost = (1.0 - s) * (vib \/ 100.0)\n            s = s + boost * s\n        hsv&#91;..., 1] = np.clip(s, 0.0, 1.0)\n        return cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)\n\n    @staticmethod\n    def _denoise(img, val):\n        if val &lt;= 0:\n            return img\n        amount = val \/ 100.0\n        sigma_color = 10 + amount * 90\n        sigma_space = 10 + amount * 90\n        arr8 = (np.clip(img, 0.0, 1.0) * 255.0).astype(np.uint8)\n        denoised = cv2.bilateralFilter(arr8, 5, sigma_color, sigma_space)\n        return denoised.astype(np.float32) \/ 255.0\n\n    @staticmethod\n    def _clarity(img, val):\n        if val == 0:\n            return img\n        amount = val \/ 100.0\n        blur = cv2.GaussianBlur(img, (0, 0), 20)\n        return img + (img - blur) * amount * 0.6\n\n    @staticmethod\n    def _sharpen(img, val):\n        if val &lt;= 0:\n            return img\n        amount = (val \/ 100.0) * 1.5\n        blur = cv2.GaussianBlur(img, (0, 0), 2.0)\n        return img + (img - blur) * amount\n\n    @staticmethod\n    def curve_lut(points):\n        \"\"\"Build a smooth 256-entry lookup table (0..1) from control points\n        given in 0..255 space, using a Catmull-Rom spline through them.\"\"\"\n        pts = sorted(points, key=lambda p: p&#91;0])\n        xs = np.array(&#91;p&#91;0] for p in pts], dtype=np.float64)\n        ys = np.array(&#91;p&#91;1] for p in pts], dtype=np.float64)\n        n = len(pts)\n        sample_x = np.arange(256, dtype=np.float64)\n\n        if n &lt; 2:\n            return np.clip(sample_x \/ 255.0, 0.0, 1.0)\n\n        lead_dx = xs&#91;1] - xs&#91;0] if xs&#91;1] != xs&#91;0] else 1.0\n        tail_dx = xs&#91;-1] - xs&#91;-2] if xs&#91;-1] != xs&#91;-2] else 1.0\n        ext_x = np.concatenate((&#91;xs&#91;0] - lead_dx], xs, &#91;xs&#91;-1] + tail_dx]))\n        ext_y = np.concatenate((&#91;ys&#91;0] - (ys&#91;1] - ys&#91;0])], ys, &#91;ys&#91;-1] + (ys&#91;-1] - ys&#91;-2])]))\n\n        sample_y = np.interp(sample_x, xs, ys)  # fallback \/ base (linear)\n        for i in range(1, len(ext_x) - 2):\n            x0, x1, x2, x3 = ext_x&#91;i - 1:i + 3]\n            y0, y1, y2, y3 = ext_y&#91;i - 1:i + 3]\n            if x2 &lt;= x1:\n                continue\n            mask = (sample_x &gt;= x1) &amp; (sample_x &lt;= x2)\n            if not np.any(mask):\n                continue\n            t = (sample_x&#91;mask] - x1) \/ (x2 - x1)\n            t2 = t * t\n            t3 = t2 * t\n            y = 0.5 * (\n                (2 * y1)\n                + (-y0 + y2) * t\n                + (2 * y0 - 5 * y1 + 4 * y2 - y3) * t2\n                + (-y0 + 3 * y1 - 3 * y2 + y3) * t3\n            )\n            sample_y&#91;mask] = y\n\n        sample_y = np.clip(sample_y, 0.0, 255.0)\n        return (sample_y \/ 255.0).astype(np.float32)\n\n    @staticmethod\n    def _apply_curve(img, lut):\n        if lut is None:\n            return img\n        idx = np.clip(img * 255.0, 0, 255).astype(np.uint8)\n        return lut&#91;idx]\n\n    @classmethod\n    def render(cls, base, params, curve_lut=None):\n        out = base.astype(np.float32, copy=True)\n        out = cls._exposure(out, params&#91;\"exposure\"])\n        out = cls._brightness(out, params&#91;\"brightness\"])\n        out = cls._contrast(out, params&#91;\"contrast\"])\n        out = cls._highlights_shadows(out, params&#91;\"highlights\"], params&#91;\"shadows\"])\n        out = np.clip(out, 0.0, 1.0)\n        out = cls._apply_curve(out, curve_lut)\n        out = cls._temperature_tint(out, params&#91;\"temperature\"], params&#91;\"tint\"])\n        out = cls._saturation_vibrance(out, params&#91;\"saturation\"], params&#91;\"vibrance\"])\n        out = cls._denoise(out, params&#91;\"denoise\"])\n        out = cls._clarity(out, params&#91;\"clarity\"])\n        out = cls._sharpen(out, params&#91;\"sharpening\"])\n        return np.clip(out, 0.0, 1.0)\n\n    @staticmethod\n    def to_qimage(img_float):\n        arr8 = (np.clip(img_float, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)\n        arr8 = np.ascontiguousarray(arr8)\n        h, w, _ = arr8.shape\n        qimg = QImage(arr8.data, w, h, w * 3, QImage.Format_RGB888)\n        return qimg.copy()\n\n    @staticmethod\n    def export(full_base, params, path, curve_lut=None):\n        out = ImageEngine.render(full_base, params, curve_lut)\n        arr8 = (np.clip(out, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)\n        img = Image.fromarray(arr8, mode=\"RGB\")\n        ext = os.path.splitext(path)&#91;1].lower()\n        if ext in (\".jpg\", \".jpeg\"):\n            img.save(path, quality=95, subsampling=0)\n        else:\n            img.save(path)\n\n\ndef default_params():\n    return {\n        \"exposure\": 0,\n        \"brightness\": 0,\n        \"contrast\": 0,\n        \"highlights\": 0,\n        \"shadows\": 0,\n        \"temperature\": 0,\n        \"tint\": 0,\n        \"saturation\": 0,\n        \"vibrance\": 0,\n        \"sharpening\": 0,\n        \"clarity\": 0,\n        \"denoise\": 0,\n    }\n\n\nDEFAULT_CURVE_POINTS = &#91;(0, 0), (255, 255)]\n\n\n# --------------------------------------------------------------------------\n# Background loader thread (keeps UI responsive while decoding RAW files)\n# --------------------------------------------------------------------------\nclass LoadThread(QThread):\n    loaded = Signal(object)\n    failed = Signal(str)\n\n    def __init__(self, path):\n        super().__init__()\n        self.path = path\n\n    def run(self):\n        try:\n            arr = ImageEngine.load_image(self.path)\n            self.loaded.emit(arr)\n        except Exception as exc:  # noqa: BLE001\n            self.failed.emit(str(exc))\n\n\n# --------------------------------------------------------------------------\n# Curves editor\n# --------------------------------------------------------------------------\nclass CurvesWidget(QWidget):\n    curveChanged = Signal(list)\n\n    PADDING = 10\n\n    def __init__(self):\n        super().__init__()\n        self.setMinimumSize(180, 180)\n        self.setMaximumHeight(220)\n        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)\n        self.setFixedSize(200, 200)\n        self.points = list(DEFAULT_CURVE_POINTS)\n        self._drag_index = None\n\n    def reset(self):\n        self.points = list(DEFAULT_CURVE_POINTS)\n        self.update()\n        self.curveChanged.emit(self.points)\n\n    def _plot_rect(self):\n        return self.rect().adjusted(self.PADDING, self.PADDING, -self.PADDING, -self.PADDING)\n\n    def _to_widget(self, px, py):\n        r = self._plot_rect()\n        x = r.left() + (px \/ 255.0) * r.width()\n        y = r.bottom() - (py \/ 255.0) * r.height()\n        return QPointF(x, y)\n\n    def _to_curve(self, wx, wy):\n        r = self._plot_rect()\n        px = (wx - r.left()) \/ max(1, r.width()) * 255.0\n        py = (r.bottom() - wy) \/ max(1, r.height()) * 255.0\n        return max(0.0, min(255.0, px)), max(0.0, min(255.0, py))\n\n    def paintEvent(self, event):\n        painter = QPainter(self)\n        painter.setRenderHint(QPainter.Antialiasing)\n        r = self._plot_rect()\n\n        painter.fillRect(self.rect(), QColor(\"#1a1a1a\"))\n        painter.setPen(QPen(QColor(\"#2c2c2c\"), 1))\n        for i in range(1, 4):\n            x = r.left() + r.width() * i \/ 4\n            painter.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()))\n            y = r.top() + r.height() * i \/ 4\n            painter.drawLine(QPointF(r.left(), y), QPointF(r.right(), y))\n\n        painter.setPen(QPen(QColor(\"#3a3a3a\"), 1, Qt.DashLine))\n        painter.drawLine(QPointF(r.left(), r.bottom()), QPointF(r.right(), r.top()))\n\n        lut = ImageEngine.curve_lut(self.points)\n        path_points = &#91;self._to_widget(x, lut&#91;x] * 255.0) for x in range(256)]\n        painter.setPen(QPen(QColor(\"#0a84ff\"), 2))\n        for a, b in zip(path_points, path_points&#91;1:]):\n            painter.drawLine(a, b)\n\n        painter.setPen(QPen(QColor(\"#ffffff\"), 1))\n        painter.setBrush(QColor(\"#0a84ff\"))\n        for px, py in self.points:\n            wp = self._to_widget(px, py)\n            painter.drawEllipse(wp, 5, 5)\n\n    def _nearest_point_index(self, pos):\n        for i, (px, py) in enumerate(self.points):\n            wp = self._to_widget(px, py)\n            if (wp - QPointF(pos)).manhattanLength() &lt; 14:\n                return i\n        return None\n\n    def mousePressEvent(self, event):\n        if event.button() != Qt.LeftButton:\n            return\n        idx = self._nearest_point_index(event.pos())\n        if idx is not None:\n            self._drag_index = idx\n        else:\n            cx, cy = self._to_curve(event.pos().x(), event.pos().y())\n            self.points.append((cx, cy))\n            self.points.sort(key=lambda p: p&#91;0])\n            self._drag_index = self.points.index((cx, cy))\n            self.update()\n            self.curveChanged.emit(self.points)\n\n    def mouseMoveEvent(self, event):\n        if self._drag_index is None:\n            return\n        cx, cy = self._to_curve(event.pos().x(), event.pos().y())\n        idx = self._drag_index\n        if idx == 0:\n            cx = 0.0\n        elif idx == len(self.points) - 1:\n            cx = 255.0\n        else:\n            lo = self.points&#91;idx - 1]&#91;0] + 2\n            hi = self.points&#91;idx + 1]&#91;0] - 2\n            cx = max(lo, min(hi, cx))\n        self.points&#91;idx] = (cx, cy)\n        self.update()\n        self.curveChanged.emit(self.points)\n\n    def mouseReleaseEvent(self, event):\n        self._drag_index = None\n\n    def mouseDoubleClickEvent(self, event):\n        idx = self._nearest_point_index(event.pos())\n        if idx is not None and 0 &lt; idx &lt; len(self.points) - 1:\n            del self.points&#91;idx]\n            self.update()\n            self.curveChanged.emit(self.points)\n\n\n# --------------------------------------------------------------------------\n# Canvas: zoom \/ pan \/ crop\n# --------------------------------------------------------------------------\nclass CanvasView(QGraphicsView):\n    def __init__(self):\n        super().__init__()\n        self._scene = QGraphicsScene(self)\n        self.setScene(self._scene)\n        self.pixmap_item = QGraphicsPixmapItem()\n        self._scene.addItem(self.pixmap_item)\n\n        self.crop_overlay_item = QGraphicsPathItem()\n        self.crop_overlay_item.setPen(QPen(Qt.NoPen))\n        self.crop_overlay_item.setBrush(QBrush(QColor(0, 0, 0, 140)))\n        self.crop_overlay_item.setZValue(10)\n        self.crop_overlay_item.setVisible(False)\n        self._scene.addItem(self.crop_overlay_item)\n\n        self.crop_border_item = QGraphicsRectItem()\n        self.crop_border_item.setPen(QPen(QColor(\"#0a84ff\"), 2, Qt.DashLine))\n        self.crop_border_item.setBrush(QBrush(Qt.NoBrush))\n        self.crop_border_item.setZValue(11)\n        self.crop_border_item.setVisible(False)\n        self._scene.addItem(self.crop_border_item)\n\n        self.setDragMode(QGraphicsView.ScrollHandDrag)\n        self.setRenderHints(QPainter.SmoothPixmapTransform | QPainter.Antialiasing)\n        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)\n        self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)\n        self._zoom = 1.0\n        self.crop_mode = False\n        self.crop_aspect = None  # None = free, else (w_ratio, h_ratio)\n        self.crop_rect = None  # QRectF in scene (image-pixel) coordinates\n        self._interaction = None  # None | \"draw\" | \"move\"\n        self._draw_origin = None\n        self._move_anchor = None\n        self._move_orig_rect = None\n\n    def set_pixmap(self, pixmap, fit=False):\n        self.pixmap_item.setPixmap(pixmap)\n        self._scene.setSceneRect(QRectF(pixmap.rect()))\n        if fit:\n            self.fit_to_window()\n\n    def fit_to_window(self):\n        if self.pixmap_item.pixmap().isNull():\n            return\n        self.resetTransform()\n        self.fitInView(self.pixmap_item, Qt.KeepAspectRatio)\n        self._zoom = self.transform().m11()\n\n    def wheelEvent(self, event):\n        if self.pixmap_item.pixmap().isNull():\n            return\n        factor = 1.15 if event.angleDelta().y() &gt; 0 else 1.0 \/ 1.15\n        new_zoom = self._zoom * factor\n        if 0.05 &lt; new_zoom &lt; 12.0:\n            self.scale(factor, factor)\n            self._zoom = new_zoom\n\n    def set_crop_mode(self, enabled):\n        self.crop_mode = enabled\n        if enabled:\n            self.setDragMode(QGraphicsView.NoDrag)\n            self.setCursor(QCursor(Qt.CrossCursor))\n            self._update_crop_visual()\n        else:\n            self.setDragMode(QGraphicsView.ScrollHandDrag)\n            self.unsetCursor()\n            self.clear_crop_selection()\n\n    def set_crop_aspect(self, ratio):\n        \"\"\"ratio: None for free-form, or (w, h) tuple such as (16, 9).\"\"\"\n        self.crop_aspect = ratio\n\n    def clear_crop_selection(self):\n        self._interaction = None\n        self.crop_rect = None\n        self.crop_border_item.setVisible(False)\n        self.crop_overlay_item.setVisible(False)\n\n    def _update_crop_visual(self):\n        if self.crop_rect is None:\n            self.crop_border_item.setVisible(False)\n            self.crop_overlay_item.setVisible(False)\n            return\n        self.crop_border_item.setRect(self.crop_rect)\n        self.crop_border_item.setVisible(True)\n\n        path = QPainterPath()\n        path.setFillRule(Qt.OddEvenFill)\n        path.addRect(self._scene.sceneRect())\n        path.addRect(self.crop_rect)\n        self.crop_overlay_item.setPath(path)\n        self.crop_overlay_item.setVisible(True)\n\n    def _draw_rect(self, origin, current):\n        \"\"\"Compute a crop rect anchored at `origin`, clipped so it never\n        exceeds the photo's bounding box, preserving the locked aspect\n        ratio (if any).\"\"\"\n        bounds = self._scene.sceneRect()\n        if self.crop_aspect is None:\n            return QRectF(origin, current).normalized().intersected(bounds)\n\n        w_r, h_r = self.crop_aspect\n        dx = current.x() - origin.x()\n        dy = current.y() - origin.y()\n        sign_x = 1.0 if dx &gt;= 0 else -1.0\n        sign_y = 1.0 if dy &gt;= 0 else -1.0\n\n        w = abs(dx) if dx != 0 else 1.0\n        max_w_x = (bounds.right() - origin.x()) if sign_x &gt; 0 else (origin.x() - bounds.left())\n        max_h_y = (bounds.bottom() - origin.y()) if sign_y &gt; 0 else (origin.y() - bounds.top())\n        max_w_from_h = max_h_y * (w_r \/ h_r)\n        w = max(1.0, min(w, max_w_x, max_w_from_h))\n        h = w * (h_r \/ w_r)\n\n        end = QPointF(origin.x() + sign_x * w, origin.y() + sign_y * h)\n        return QRectF(origin, end).normalized()\n\n    def _clamp_to_bounds(self, rect):\n        \"\"\"Slide (without resizing) a rect so it stays fully inside the\n        photo's bounding box. The crop rect is always created within\n        bounds, so it can never be larger than the photo itself.\"\"\"\n        bounds = self._scene.sceneRect()\n        dx = 0.0\n        if rect.left() &lt; bounds.left():\n            dx = bounds.left() - rect.left()\n        elif rect.right() &gt; bounds.right():\n            dx = bounds.right() - rect.right()\n        dy = 0.0\n        if rect.top() &lt; bounds.top():\n            dy = bounds.top() - rect.top()\n        elif rect.bottom() &gt; bounds.bottom():\n            dy = bounds.bottom() - rect.bottom()\n        return rect.translated(dx, dy)\n\n    def mousePressEvent(self, event):\n        if self.crop_mode and event.button() == Qt.LeftButton:\n            scene_pos = self.mapToScene(event.pos())\n            if self.crop_rect is not None and self.crop_rect.contains(scene_pos):\n                self._interaction = \"move\"\n                self._move_anchor = scene_pos\n                self._move_orig_rect = QRectF(self.crop_rect)\n            else:\n                self._interaction = \"draw\"\n                self._draw_origin = scene_pos\n                self.crop_rect = QRectF(scene_pos, scene_pos)\n                self._update_crop_visual()\n        else:\n            super().mousePressEvent(event)\n\n    def mouseMoveEvent(self, event):\n        if self.crop_mode and self._interaction is not None:\n            scene_pos = self.mapToScene(event.pos())\n            if self._interaction == \"draw\":\n                self.crop_rect = self._draw_rect(self._draw_origin, scene_pos)\n            elif self._interaction == \"move\":\n                delta = scene_pos - self._move_anchor\n                moved = self._move_orig_rect.translated(delta.x(), delta.y())\n                self.crop_rect = self._clamp_to_bounds(moved)\n            self._update_crop_visual()\n        else:\n            super().mouseMoveEvent(event)\n\n    def mouseReleaseEvent(self, event):\n        if self.crop_mode and self._interaction is not None:\n            self._interaction = None\n            if self.crop_rect is not None and (\n                self.crop_rect.width() &lt; 5 or self.crop_rect.height() &lt; 5\n            ):\n                self.crop_rect = None\n                self._update_crop_visual()\n        else:\n            super().mouseReleaseEvent(event)\n\n\n# --------------------------------------------------------------------------\n# Tab \/ slider definitions\n# --------------------------------------------------------------------------\nTABS = {\n    \"Light\": &#91;\n        (\"exposure\", \"Exposure\", -100, 100),\n        (\"brightness\", \"Brightness\", -100, 100),\n        (\"contrast\", \"Contrast\", -100, 100),\n        (\"highlights\", \"Highlights\", -100, 100),\n        (\"shadows\", \"Shadows\", -100, 100),\n    ],\n    \"Color\": &#91;\n        (\"temperature\", \"Temperature\", -100, 100),\n        (\"tint\", \"Tint\", -100, 100),\n        (\"saturation\", \"Saturation\", -100, 100),\n        (\"vibrance\", \"Vibrance\", -100, 100),\n    ],\n    \"Detail\": &#91;\n        (\"sharpening\", \"Sharpening\", 0, 100),\n        (\"clarity\", \"Clarity\", -100, 100),\n        (\"denoise\", \"Denoise\", 0, 100),\n    ],\n}\nNAV_ORDER = &#91;\"Light\", \"Color\", \"Detail\", \"Curves\", \"Transform\"]\nAUTO_SUPPORTED_KEYS = {\"exposure\", \"contrast\"}\nCROP_PRESETS = &#91;\n    (\"Free\", None),\n    (\"1:1\", (1, 1)),\n    (\"4:3\", (4, 3)),\n    (\"3:2\", (3, 2)),\n    (\"16:9\", (16, 9)),\n]\n\n\n# --------------------------------------------------------------------------\n# Main window\n# --------------------------------------------------------------------------\nclass MainWindow(QMainWindow):\n    def __init__(self):\n        super().__init__()\n        self.setWindowTitle(APP_NAME)\n        self.resize(1200, 800)\n\n        self.params = default_params()\n        self.crop_base_ratio = None\n        self.crop_landscape = True\n        self.curve_points = list(DEFAULT_CURVE_POINTS)\n        self.curve_lut = ImageEngine.curve_lut(self.curve_points)\n        self.full_base = None\n        self.preview_base = None\n        self.current_path = None\n        self.current_param_key = None\n        self.load_thread = None\n        self.auto_btn = None\n\n        self._debounce = QTimer(self)\n        self._debounce.setSingleShot(True)\n        self._debounce.timeout.connect(self.refresh_preview)\n\n        self._build_ui()\n        self._select_tab(\"Light\")\n\n    # -- UI construction ----------------------------------------------\n    def _build_ui(self):\n        central = QWidget()\n        self.setCentralWidget(central)\n        root = QVBoxLayout(central)\n        root.setContentsMargins(0, 0, 0, 0)\n        root.setSpacing(0)\n\n        root.addWidget(self._build_top_bar())\n\n        self.canvas = CanvasView()\n        root.addWidget(self.canvas, stretch=1)\n\n        root.addWidget(self._build_slider_area())\n        root.addWidget(self._build_bottom_nav())\n\n    def _build_top_bar(self):\n        bar = QWidget()\n        bar.setObjectName(\"topBar\")\n        bar.setFixedHeight(56)\n        layout = QHBoxLayout(bar)\n        layout.setContentsMargins(16, 8, 16, 8)\n\n        self.open_btn = QPushButton(\"Open File\")\n        self.open_btn.clicked.connect(self.open_file)\n        layout.addWidget(self.open_btn)\n\n        self.file_label = QLabel(\"No image loaded\")\n        self.file_label.setObjectName(\"fileLabel\")\n        layout.addWidget(self.file_label, stretch=1)\n        self.file_label.setAlignment(Qt.AlignCenter)\n\n        self.save_btn = QPushButton(\"Save Copy\")\n        self.save_btn.setObjectName(\"primaryButton\")\n        self.save_btn.setEnabled(False)\n        self.save_btn.clicked.connect(self.save_copy)\n        layout.addWidget(self.save_btn)\n\n        return bar\n\n    def _build_slider_area(self):\n        area = QWidget()\n        area.setObjectName(\"sliderArea\")\n        area.setFixedHeight(230)\n        outer = QVBoxLayout(area)\n        outer.setContentsMargins(16, 8, 16, 8)\n\n        self.slider_stack = QStackedWidget()\n        outer.addWidget(self.slider_stack)\n\n        # -- page 0: parameter sliders (shared across Light\/Color\/Detail)\n        self.sliders_page = QWidget()\n        sp_layout = QVBoxLayout(self.sliders_page)\n        sp_layout.setContentsMargins(0, 0, 0, 0)\n        sp_layout.setSpacing(6)\n\n        self.chip_row = QHBoxLayout()\n        self.chip_row.setSpacing(8)\n        chip_row_widget = QWidget()\n        chip_row_widget.setLayout(self.chip_row)\n        sp_layout.addWidget(chip_row_widget)\n\n        slider_row = QHBoxLayout()\n        self.slider_title = QLabel(\"Exposure\")\n        self.slider_title.setObjectName(\"sliderTitle\")\n        self.slider_title.setFixedWidth(100)\n        slider_row.addWidget(self.slider_title)\n\n        self.slider = QSlider(Qt.Horizontal)\n        self.slider.setMinimum(-100)\n        self.slider.setMaximum(100)\n        self.slider.valueChanged.connect(self._on_slider_changed)\n        slider_row.addWidget(self.slider, stretch=1)\n\n        self.slider_value = QLabel(\"0\")\n        self.slider_value.setObjectName(\"sliderValue\")\n        self.slider_value.setAlignment(Qt.AlignCenter)\n        slider_row.addWidget(self.slider_value)\n\n        self.reset_btn = QPushButton(\"Reset\")\n        self.reset_btn.setObjectName(\"resetButton\")\n        self.reset_btn.clicked.connect(self._reset_current_slider)\n        slider_row.addWidget(self.reset_btn)\n\n        sp_layout.insertStretch(0, 1)\n        sp_layout.addLayout(slider_row)\n        sp_layout.addStretch(1)\n        self.slider_stack.addWidget(self.sliders_page)\n\n        # -- page 1: curves\n        self.curves_page = QWidget()\n        cv_layout = QHBoxLayout(self.curves_page)\n        cv_layout.setContentsMargins(0, 0, 0, 0)\n        cv_layout.setAlignment(Qt.AlignCenter)\n        cv_layout.setSpacing(16)\n\n        self.curves_widget = CurvesWidget()\n        self.curves_widget.curveChanged.connect(self._on_curve_changed)\n        cv_layout.addWidget(self.curves_widget)\n\n        curve_reset_btn = QPushButton(\"Reset\")\n        curve_reset_btn.setObjectName(\"resetButton\")\n        curve_reset_btn.clicked.connect(self._reset_curve)\n        cv_layout.addWidget(curve_reset_btn, alignment=Qt.AlignTop)\n\n        self.slider_stack.addWidget(self.curves_page)\n\n        # -- page 2: transform actions (default sub-page)\n        self.transform_page = QWidget()\n        tp_outer = QVBoxLayout(self.transform_page)\n        tp_outer.setContentsMargins(0, 0, 0, 0)\n        tp_outer.addStretch(1)\n        tp_row = QHBoxLayout()\n        tp_row.setSpacing(10)\n        tp_row.setAlignment(Qt.AlignCenter)\n\n        self.crop_btn = QPushButton(\"Crop\")\n        self.crop_btn.clicked.connect(self._enter_crop_mode)\n        tp_row.addWidget(self.crop_btn)\n\n        rotate_btn = QPushButton(\"Rotate 90\u00b0\")\n        rotate_btn.clicked.connect(self._rotate90)\n        tp_row.addWidget(rotate_btn)\n\n        flip_h_btn = QPushButton(\"Flip Horizontal\")\n        flip_h_btn.clicked.connect(self._flip_horizontal)\n        tp_row.addWidget(flip_h_btn)\n\n        flip_v_btn = QPushButton(\"Flip Vertical\")\n        flip_v_btn.clicked.connect(self._flip_vertical)\n        tp_row.addWidget(flip_v_btn)\n\n        tp_outer.addLayout(tp_row)\n        tp_outer.addStretch(1)\n        self.slider_stack.addWidget(self.transform_page)\n\n        # -- page 3: crop options (aspect presets + cancel\/apply)\n        self.crop_options_page = QWidget()\n        cp_outer = QVBoxLayout(self.crop_options_page)\n        cp_outer.setContentsMargins(0, 0, 0, 0)\n        cp_outer.addStretch(1)\n        cp_layout = QVBoxLayout()\n        cp_layout.setSpacing(10)\n\n        preset_row = QHBoxLayout()\n        preset_row.setSpacing(8)\n        self.crop_preset_group = QButtonGroup(self)\n        self.crop_preset_group.setExclusive(True)\n        for label, ratio in CROP_PRESETS:\n            chip = QPushButton(label)\n            chip.setObjectName(\"chipButton\")\n            chip.setCheckable(True)\n            chip.clicked.connect(lambda checked, r=ratio: self._set_crop_aspect(r))\n            self.crop_preset_group.addButton(chip)\n            preset_row.addWidget(chip)\n        preset_row.itemAt(0).widget().setChecked(True)\n        preset_row.addStretch(1)\n\n        self.orientation_btn = QPushButton(\"Landscape\")\n        self.orientation_btn.setEnabled(False)\n        self.orientation_btn.clicked.connect(self._toggle_crop_orientation)\n        preset_row.addWidget(self.orientation_btn)\n\n        cp_layout.addLayout(preset_row)\n\n        action_row = QHBoxLayout()\n        action_row.setAlignment(Qt.AlignCenter)\n        action_row.setSpacing(12)\n\n        cancel_crop_btn = QPushButton(\"Cancel\")\n        cancel_crop_btn.clicked.connect(self._cancel_crop)\n        action_row.addWidget(cancel_crop_btn)\n\n        apply_crop_btn = QPushButton(\"Apply\")\n        apply_crop_btn.setObjectName(\"primaryButton\")\n        apply_crop_btn.clicked.connect(self._confirm_crop)\n        action_row.addWidget(apply_crop_btn)\n\n        cp_layout.addLayout(action_row)\n        cp_outer.addLayout(cp_layout)\n        cp_outer.addStretch(1)\n        self.slider_stack.addWidget(self.crop_options_page)\n\n        return area\n\n    def _build_bottom_nav(self):\n        nav = QWidget()\n        nav.setObjectName(\"bottomNav\")\n        nav.setFixedHeight(56)\n        layout = QHBoxLayout(nav)\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.setSpacing(0)\n\n        self.nav_group = QButtonGroup(self)\n        self.nav_group.setExclusive(True)\n        self.nav_buttons = {}\n        for name in NAV_ORDER:\n            btn = QPushButton(name)\n            btn.setObjectName(\"navButton\")\n            btn.setCheckable(True)\n            btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)\n            btn.clicked.connect(lambda checked, n=name: self._select_tab(n))\n            self.nav_group.addButton(btn)\n            self.nav_buttons&#91;name] = btn\n            layout.addWidget(btn)\n\n        return nav\n\n    # -- tab \/ chip handling --------------------------------------------\n    def _select_tab(self, name):\n        self.nav_buttons&#91;name].setChecked(True)\n        self.canvas.set_crop_mode(False)\n\n        if name == \"Transform\":\n            self.slider_stack.setCurrentWidget(self.transform_page)\n            return\n\n        if name == \"Curves\":\n            self.slider_stack.setCurrentWidget(self.curves_page)\n            return\n\n        self.slider_stack.setCurrentWidget(self.sliders_page)\n\n        # rebuild chip row for this tab\n        while self.chip_row.count():\n            item = self.chip_row.takeAt(0)\n            if item.widget():\n                item.widget().deleteLater()\n        self.auto_btn = None\n\n        chip_group = QButtonGroup(self)\n        chip_group.setExclusive(True)\n        self._chip_group = chip_group\n        for key, label, lo, hi in TABS&#91;name]:\n            chip = QPushButton(label)\n            chip.setObjectName(\"chipButton\")\n            chip.setCheckable(True)\n            chip.clicked.connect(\n                lambda checked, k=key, l=label, a=lo, b=hi: self._select_param(k, l, a, b)\n            )\n            chip_group.addButton(chip)\n            self.chip_row.addWidget(chip)\n        self.chip_row.addStretch(1)\n\n        if name == \"Light\":\n            self.auto_btn = QPushButton(\"Auto\")\n            self.auto_btn.setObjectName(\"primaryButton\")\n            self.auto_btn.clicked.connect(self._auto_light_balance)\n            self.chip_row.addWidget(self.auto_btn)\n\n        first = TABS&#91;name]&#91;0]\n        self.chip_row.itemAt(0).widget().setChecked(True)\n        self._select_param(first&#91;0], first&#91;1], first&#91;2], first&#91;3])\n\n    def _select_param(self, key, label, lo, hi):\n        self.current_param_key = key\n        self.slider_title.setText(label)\n        self.slider.blockSignals(True)\n        self.slider.setMinimum(lo)\n        self.slider.setMaximum(hi)\n        self.slider.setValue(self.params&#91;key])\n        self.slider.blockSignals(False)\n        self.slider_value.setText(str(self.params&#91;key]))\n        if self.auto_btn is not None:\n            self.auto_btn.setEnabled(key in AUTO_SUPPORTED_KEYS)\n\n    def _on_slider_changed(self, value):\n        if self.current_param_key is None:\n            return\n        self.params&#91;self.current_param_key] = value\n        self.slider_value.setText(str(value))\n        self._debounce.start(15)\n\n    def _reset_current_slider(self):\n        if self.current_param_key is None:\n            return\n        default = 0\n        self.params&#91;self.current_param_key] = default\n        self.slider.blockSignals(True)\n        self.slider.setValue(default)\n        self.slider.blockSignals(False)\n        self.slider_value.setText(str(default))\n        self.refresh_preview()\n\n    def _auto_light_balance(self):\n        \"\"\"One-tap auto exposure\/contrast fix based on the image histogram.\"\"\"\n        if self.preview_base is None:\n            return\n        luminance = np.clip(\n            self.preview_base&#91;..., 0] * 0.299\n            + self.preview_base&#91;..., 1] * 0.587\n            + self.preview_base&#91;..., 2] * 0.114,\n            0.0,\n            1.0,\n        )\n        mean_lum = float(luminance.mean())\n        target_mean = 0.45\n        ev = np.log2(target_mean \/ mean_lum) if mean_lum &gt; 1e-4 else 0.0\n        ev = float(np.clip(ev, -1.5, 1.5))\n        exposure_val = int(np.clip((ev \/ 2.0) * 100, -100, 100))\n\n        p1, p99 = np.percentile(luminance, &#91;1, 99])\n        spread = max(p99 - p1, 1e-3)\n        contrast_factor = float(np.clip(0.85 \/ spread, 0.8, 1.6))\n        contrast_val = int(np.clip((contrast_factor - 1.0) * 100, -100, 100))\n\n        self.params&#91;\"exposure\"] = exposure_val\n        self.params&#91;\"contrast\"] = contrast_val\n\n        if self.current_param_key in AUTO_SUPPORTED_KEYS:\n            self.slider.blockSignals(True)\n            self.slider.setValue(self.params&#91;self.current_param_key])\n            self.slider.blockSignals(False)\n            self.slider_value.setText(str(self.params&#91;self.current_param_key]))\n\n        self.refresh_preview()\n\n    # -- curves -----------------------------------------------------------\n    def _on_curve_changed(self, points):\n        self.curve_points = list(points)\n        self.curve_lut = ImageEngine.curve_lut(self.curve_points)\n        self._debounce.start(15)\n\n    def _reset_curve(self):\n        self.curves_widget.reset()\n\n    # -- crop options -------------------------------------------------\n    def _enter_crop_mode(self):\n        if self.full_base is None:\n            return\n        self.slider_stack.setCurrentWidget(self.crop_options_page)\n        self.canvas.set_crop_mode(True)\n\n    def _set_crop_aspect(self, ratio):\n        self.crop_base_ratio = ratio\n        self.orientation_btn.setEnabled(ratio is not None and ratio&#91;0] != ratio&#91;1])\n        self._apply_crop_orientation()\n\n    def _apply_crop_orientation(self):\n        ratio = self.crop_base_ratio\n        if ratio is not None and not self.crop_landscape:\n            ratio = (ratio&#91;1], ratio&#91;0])\n        self.canvas.set_crop_aspect(ratio)\n        self.canvas.clear_crop_selection()\n\n    def _toggle_crop_orientation(self):\n        self.crop_landscape = not self.crop_landscape\n        self.orientation_btn.setText(\"Landscape\" if self.crop_landscape else \"Portrait\")\n        self._apply_crop_orientation()\n\n    def _cancel_crop(self):\n        self.canvas.set_crop_mode(False)\n        self.slider_stack.setCurrentWidget(self.transform_page)\n\n    def _confirm_crop(self):\n        rect = self.canvas.crop_rect\n        if rect is not None:\n            self._apply_crop(rect)\n        self.canvas.set_crop_mode(False)\n        self.slider_stack.setCurrentWidget(self.transform_page)\n\n    # -- file open \/ save -------------------------------------------------\n    def open_file(self):\n        path, _ = QFileDialog.getOpenFileName(self, \"Open Image\", \"\", OPEN_FILTER)\n        if not path:\n            return\n        self.open_btn.setEnabled(False)\n        self.save_btn.setEnabled(False)\n        self.file_label.setText(f\"Loading {os.path.basename(path)}...\")\n        self.current_path = path\n\n        self.load_thread = LoadThread(path)\n        self.load_thread.loaded.connect(self._on_loaded)\n        self.load_thread.failed.connect(self._on_load_failed)\n        self.load_thread.start()\n\n    def _on_loaded(self, arr):\n        self.full_base = arr\n        self.preview_base = ImageEngine.make_preview(arr)\n        self.params = default_params()\n        self.curves_widget.reset()\n        self.file_label.setText(os.path.basename(self.current_path))\n        self.open_btn.setEnabled(True)\n        self.save_btn.setEnabled(True)\n        self.refresh_preview(fit=True)\n        self._select_tab(\"Light\")\n\n    def _on_load_failed(self, message):\n        self.open_btn.setEnabled(True)\n        self.file_label.setText(\"No image loaded\")\n        QMessageBox.critical(self, APP_NAME, f\"Could not open image:\\n{message}\")\n\n    def save_copy(self):\n        if self.full_base is None:\n            return\n        base = os.path.splitext(os.path.basename(self.current_path or \"export\"))&#91;0]\n        path, _ = QFileDialog.getSaveFileName(\n            self,\n            \"Save Copy\",\n            f\"{base}_edited.jpg\",\n            \"JPEG (*.jpg);;PNG (*.png)\",\n        )\n        if not path:\n            return\n        try:\n            ImageEngine.export(self.full_base, self.params, path, self.curve_lut)\n        except Exception as exc:  # noqa: BLE001\n            QMessageBox.critical(self, APP_NAME, f\"Could not save image:\\n{exc}\")\n            return\n        QMessageBox.information(self, APP_NAME, f\"Saved to {path}\")\n\n    # -- preview rendering -------------------------------------------------\n    def refresh_preview(self, fit=False):\n        if self.preview_base is None:\n            return\n        out = ImageEngine.render(self.preview_base, self.params, self.curve_lut)\n        qimg = ImageEngine.to_qimage(out)\n        self.canvas.set_pixmap(QPixmap.fromImage(qimg), fit=fit)\n\n    # -- transform actions ---------------------------------------------\n    def _rotate90(self):\n        if self.full_base is None:\n            return\n        self.full_base = np.ascontiguousarray(np.rot90(self.full_base, k=-1))\n        self.preview_base = np.ascontiguousarray(np.rot90(self.preview_base, k=-1))\n        self.refresh_preview(fit=True)\n\n    def _flip_horizontal(self):\n        if self.full_base is None:\n            return\n        self.full_base = np.ascontiguousarray(np.fliplr(self.full_base))\n        self.preview_base = np.ascontiguousarray(np.fliplr(self.preview_base))\n        self.refresh_preview(fit=True)\n\n    def _flip_vertical(self):\n        if self.full_base is None:\n            return\n        self.full_base = np.ascontiguousarray(np.flipud(self.full_base))\n        self.preview_base = np.ascontiguousarray(np.flipud(self.preview_base))\n        self.refresh_preview(fit=True)\n\n    def _apply_crop(self, scene_rect):\n        if self.preview_base is None or self.full_base is None:\n            return\n        ph, pw = self.preview_base.shape&#91;:2]\n        nx0 = max(0.0, scene_rect.left() \/ pw)\n        ny0 = max(0.0, scene_rect.top() \/ ph)\n        nx1 = min(1.0, scene_rect.right() \/ pw)\n        ny1 = min(1.0, scene_rect.bottom() \/ ph)\n        if nx1 - nx0 &lt; 0.02 or ny1 - ny0 &lt; 0.02:\n            return\n\n        fh, fw = self.full_base.shape&#91;:2]\n        fx0, fx1 = int(nx0 * fw), int(nx1 * fw)\n        fy0, fy1 = int(ny0 * fh), int(ny1 * fh)\n        self.full_base = np.ascontiguousarray(self.full_base&#91;fy0:fy1, fx0:fx1])\n\n        px0, px1 = int(nx0 * pw), int(nx1 * pw)\n        py0, py1 = int(ny0 * ph), int(ny1 * ph)\n        self.preview_base = np.ascontiguousarray(self.preview_base&#91;py0:py1, px0:px1])\n\n        self.refresh_preview(fit=True)\n\n\ndef main():\n    app = QApplication(sys.argv)\n    app.setApplicationName(APP_NAME)\n    app.setStyleSheet(STYLE_SHEET)\n    window = MainWindow()\n    window.show()\n    sys.exit(app.exec())\n\n\nif __name__ == \"__main__\":\n    main()\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The AI converstation<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I asked Google Gemini to write the prompt to give to AI.  Gemini gave me the prompt.md file to then give to Claude.<\/p>\n\n\n\n<pre class=\"wp-block-code has-custom-css wp-custom-css-0efefa5a\"><code>BUILDING \"GALAXY RAW EDITOR\" WITH CLAUDE CODE\nA conversation transcript\n================================================================\n\nNote on this transcript: the repetitive mechanical steps that happened\nseveral times over the course of this build (installing dependencies,\ncompiling, running automated smoke tests, rebuilding with PyInstaller,\nrepackaging the AppImage, relaunching) have been condensed into short\nbracketed notes so the conversation reads cleanly. All quoted dialogue\nbelow is verbatim.\n\n----------------------------------------------------------------------\nTHE BRIEF\n----------------------------------------------------------------------\n\nUser (via a project file, prompt.md):\n\nBuild a lightweight, native Linux desktop application for Zorin OS that\nfunctions as a simple RAW photo editor with an interface modeled directly\nafter Samsung's Galaxy Photo Editor.\n\nCore App Specifications &amp; Requirements:\n\n1. System &amp; Dependencies Management:\n   - Before building, check if the system has all required system\n     dependencies installed (Python 3, pip, libraw-dev, libraw-bin,\n     pkg-config, build-essential, appimagetool, or appimage-builder).\n   - Automatically run `sudo apt update &amp;&amp; sudo apt install -y ...` or\n     install Python virtual environment packages (PySide6, rawpy, numpy,\n     opencv-python, Pillow) as needed.\n\n2. UI &amp; User Experience (Samsung One UI Style):\n   - Theme: Clean, modern dark theme (dark grey\/black background, rounded\n     corners, sleek sliders).\n   - Canvas: Large central image preview area that dynamically resizes\n     with the window. Include pinch\/scroll to zoom and drag to pan.\n   - Layout:\n     - Top Bar: Simple 'Open File' button, image file name, and a\n       prominent 'Save Copy' (Export) button.\n     - Center: Real-time image preview.\n     - Slider Controls Area: Placed directly above the bottom navigation\n       bar. Shows the currently selected slider with a reset button.\n     - Bottom Navigation Bar: Large, touch\/mouse-friendly horizontal tab\n       buttons with clean icons or labels:\n       - &#91; Light ] (Sliders: Exposure, Contrast, Highlights, Shadows)\n       - &#91; Color ] (Sliders: Temperature\/WB, Tint, Saturation, Vibrance)\n       - &#91; Detail ] (Sliders: Sharpening, Clarity)\n       - &#91; Transform ] (Crop, Rotate 90\u00b0, Flip)\n\n3. RAW Processing Backend:\n   - Use rawpy (LibRaw bindings) and numpy\/OpenCV in Python to process\n     camera RAW files (.ORF, .CR2, .NEF, .ARW, .DNG) as well as standard\n     image formats (.JPG, .PNG).\n   - Automatically decode RAW images on open using standard\n     auto-white-balance baseline.\n   - Maintain fast, real-time preview updates when dragging sliders\n     (process at screen preview resolution during slider drag, apply\n     full resolution on export).\n\n4. Exporting:\n   - Clicking 'Save Copy' renders the full-resolution RAW file with\n     applied adjustments and exports it as a high-quality JPEG or PNG to\n     the user's selected location.\n\n5. Packaging into AppImage:\n   - Once the application code (main.py) is working, automatically write\n     a build script or use appimagetool \/ PyInstaller to compile the app\n     and all Python\/C dependencies into a single, fully portable\n     .AppImage executable named GalaxyRawEditor-x86_64.AppImage in the\n     root folder.\n   - Make the .AppImage executable (chmod +x).\n\nPlease build the application step-by-step, verify that the dependencies\nare installed, run a test launch, and assemble the final AppImage.\n\nUser: Please build this application step by step.\n\n----------------------------------------------------------------------\nBUILDING THE APP\n----------------------------------------------------------------------\n\nClaude: I'll build this application step by step: check dependencies,\ninstall what's missing, write the PySide6 RAW editor, test it, then\npackage as an AppImage. Let's start with a dependency audit.\n\n&#91;Claude ran a dependency audit of the system.]\n\nClaude: Root packages are missing (libraw-dev, libraw-bin, pkg-config,\nlibfuse2) and sudo needs a password I can't supply from here. Please run\nthis yourself \u2014 you can type it directly, or prefix with `!` in this\nchat so I see the output:\n\n    sudo apt update &amp;&amp; sudo apt install -y python3-venv libraw-dev\n    libraw-bin pkg-config build-essential libfuse2\n\nClaude: While you run that, I'll get the Python virtual environment and\napp code moving in parallel \u2014 no sudo needed for those.\n\n&#91;Claude created a Python virtual environment.]\n\nClaude: Venv created successfully. Now let me write the application code\nwhile dependencies install.\n\n&#91;Claude wrote the full main.py application: a PySide6 dark-themed UI with\na top bar, zoomable\/pannable canvas, a slider control area, and a bottom\nnavigation bar (Light \/ Color \/ Detail \/ Transform); and a RAW\/standard\nimage processing engine built on rawpy, NumPy and OpenCV.]\n\nClaude: Let me clean up a leftover dead-code line I noticed while writing\nthat.\n\nClaude: Now let's check if you've run the apt install yet, and in the\nmeantime start pulling appimagetool.\n\n&#91;The system packages were still not installed at this point. Claude\nproceeded with everything that didn't require them.]\n\nClaude: All Python dependencies installed via prebuilt wheels (no system\nlibraries needed for that part). Now let's verify the code imports and\ncompiles cleanly.\n\n&#91;The code compiled cleanly on the first pass.]\n\nClaude: Now let's try an actual launch, using a virtual display since\nthis is likely headless.\n\nClaude: The app booted and ran its event loop cleanly under the\noffscreen platform (exit 124 = timeout killed it after 6s, no crash) \u2014\nthat confirms the code initializes correctly. Let's also try a real\nvisible window via Wayland.\n\nClaude: Good \u2014 it boots and runs its event loop cleanly on both\noffscreen and Wayland platforms with no errors. I can't visually confirm\nthe UI from this sandboxed shell (no screenshot capability), so I'd\nrecommend you launch it yourself once we're done to eyeball the look.\nLet me check on the system package install.\n\nClaude: Network access confirmed. Downloading appimagetool (the standard\nofficial AppImage packaging tool from the AppImage GitHub project).\n\nClaude: Now let's generate a simple app icon and run PyInstaller to\nbundle the app with all its dependencies.\n\n&#91;Claude generated a simple dark, One-UI-style camera icon with Pillow,\nthen ran PyInstaller to freeze the app together with PySide6, rawpy,\nNumPy, OpenCV and Pillow into a single self-contained bundle.]\n\nClaude: Build succeeded. Let's test-run the frozen binary before\npackaging into an AppImage.\n\n&#91;The frozen binary booted cleanly.]\n\nClaude: Now let's assemble the AppDir and build the final AppImage.\n\n&#91;Claude assembled an AppDir (AppRun script, .desktop file, icon) and ran\nappimagetool to produce GalaxyRawEditor-x86_64.AppImage.]\n\nClaude: AppImage built successfully. Let's confirm it's executable and\nnamed correctly, then test-run it.\n\n&#91;A few test launches inside the sandboxed shell aborted specifically\nwhen a headless \"offscreen\" test platform was forced through the\nAppImage's own runtime wrapper \u2014 a quirk of that one test mode, not a\nreal bug. Claude diagnosed this live.]\n\nClaude: This is actually your live desktop session \u2014 the default launch\n(no forced platform) ran cleanly for the full 8 seconds with just a\nharmless Wayland\/XWayland info message, no crash. The earlier abort only\nhappened when I forced the offscreen test platform through the AppImage\nruntime wrapper \u2014 not a real issue. Let me launch it for real so you can\nsee it on screen.\n\nClaude: AppImageLauncher (installed on this system) intercepted the\nlaunch \u2014 it likely popped up a dialog on your screen asking whether to\nintegrate the app into your menu. Could you check your screen and let me\nknow what you see \/ respond to it? Meanwhile let me check the process\nstate.\n\nClaude: The app is now running (PID 587072) \u2014 AppImageLauncher\nauto-integrated it and copied it to ~\/Applications\/. Please check your\nscreen for the Galaxy RAW Editor window.\n\nClaude asked: \"Can you see the Galaxy RAW Editor window on screen, and\ndoes the dark One UI-style layout (top bar, canvas, sliders, bottom nav)\nlook right?\"\nUser selected: \"Visible but issues.\"\n\nClaude: What issues are you seeing? A screenshot or description would\nhelp me fix it quickly.\n\n----------------------------------------------------------------------\nROUND 1 OF FEEDBACK\n----------------------------------------------------------------------\n\nUser: 1. Opening a file, .orf and .jpg files are not listed as images.\n2. Cropping should have presets such as 16:9 for quick selection.\n3. All dialogs, like the cropping, should have a cancel to return back.\n4. There should be an curves tool.\n\nClaude: Good catches \u2014 all four are worth fixing. Let me address them:\ncase-insensitive file filters, crop aspect-ratio presets with a proper\nCancel\/Apply flow, and a new Curves tool. Let me re-read the current\nfile first to make precise edits.\n\n&#91;Claude rewrote the file-open filter to include both upper- and\nlower-case extensions (so .ORF and .JPG show up regardless of case);\nadded a Catmull-Rom curves engine and an interactive tone-curve editor\nwidget as a new \"Curves\" tab; and redesigned the Transform tab's crop\nflow with aspect-ratio preset chips (Free \/ 1:1 \/ 4:3 \/ 3:2 \/ 16:9) and\nexplicit Cancel \/ Apply buttons.]\n\nClaude: Good, both cases are covered now. Let's boot-test the whole app\nunder the offscreen platform to catch any runtime errors in the new code\npaths.\n\nClaude: All new logic checks out. Now let's rebuild the frozen binary\nand repackage the AppImage with these fixes.\n\n&#91;Claude rebuilt the PyInstaller bundle and the AppImage, then launched\nit for the user.]\n\nClaude: The app is now running \u2014 AppImageLauncher auto-integrated it and\ncopied it to ~\/Applications\/. It includes all four fixes: case-\ninsensitive file filters (.ORF\/.JPG now show up), crop aspect presets\n(Free\/1:1\/4:3\/3:2\/16:9) with Cancel\/Apply buttons, and a new Curves tab\nin the bottom nav. Could you check it out and let me know how it looks?\n\n----------------------------------------------------------------------\nROUND 2 OF FEEDBACK\n----------------------------------------------------------------------\n\nUser: add a landscape or portrait button to change the crop orientation.\nAlso add a denoise option to the detail page.\n\nClaude: Good \u2014 I'll add both. A Landscape\/Portrait toggle next to the\ncrop aspect presets, and a Denoise slider on the Detail tab.\n\n&#91;Claude added a bilateral-filter-based Denoise slider to the Detail tab,\nand an orientation toggle button next to the crop aspect-ratio chips\nthat flips the current preset's ratio (e.g. 16:9 &lt;-&gt; 9:16) and clears\nthe pending selection so the user redraws with the new orientation.]\n\n&#91;Claude rebuilt and repackaged the AppImage, then relaunched it.]\n\nClaude: The rebuilt app is now running (AppImageLauncher auto-integrated\nit into ~\/Applications\/ again) with: a Denoise slider added to the\nDetail tab, and a Landscape\/Portrait orientation toggle next to the crop\naspect presets (enabled when a non-square preset like 4:3\/3:2\/16:9 is\nselected; flips the ratio and clears the current selection so you redraw\nwith the new orientation). Take a look and let me know if it works as\nexpected.\n\n----------------------------------------------------------------------\nROUND 3 OF FEEDBACK\n----------------------------------------------------------------------\n\nUser: add a light balance command and a brightness command to the light\nsection\n\nClaude: \"Brightness\" is a clear, standard slider (additive brightness,\ndistinct from the existing multiplicative Exposure control) \u2014 I'll add\nthat as a slider. But \"Light Balance\" isn't a term with one obvious\nmeaning in photo editors, so I want to get it right before building it.\n\nClaude asked: \"What should 'Light Balance' do?\" \u2014 offering a one-tap\nAuto button (histogram-based auto exposure\/contrast fix), a midtone\nbalance slider, or \"something else.\"\nUser chose: \"One-tap Auto button.\"\n\n&#91;Claude added a Brightness slider (additive, distinct from the existing\nmultiplicative Exposure control) to the Light tab, and a one-tap \"Auto\"\nbutton that analyzes the image histogram and automatically sets Exposure\nand Contrast.]\n\n&#91;Claude rebuilt, repackaged, and relaunched the app.]\n\nClaude: The app is running with the new build. Please check the Light\ntab for the new Brightness slider and Auto button, and let me know how\nthey look\/behave.\n\nUser: does the auto button work with all of the lighting commands?\n\nClaude: Right now it only adjusts Exposure and Contrast \u2014 it doesn't\ntouch Brightness, Highlights, or Shadows. Let me expand it to a fuller\nauto-tone fix: I'll add automatic highlight recovery (pulls down\nblown-out highlights) and shadow lift (recovers crushed blacks) based on\nthe histogram. I'll leave Brightness alone since it overlaps with\nExposure \u2014 auto-adjusting both would double-correct the same thing; you\ncan still fine-tune Brightness manually afterward.\n\n&#91;Claude expanded the Auto button to also compute Highlights and Shadows\nrecovery from the histogram, verified it against a synthetic\nblown-highlights\/crushed-shadows test image, rebuilt, repackaged, and\nrelaunched the app.]\n\nClaude: To directly answer your question: now yes, mostly \u2014 Auto\nadjusts Exposure, Contrast, Highlights, and Shadows together based on\nthe image's histogram (recovering blown highlights, lifting crushed\nshadows, correcting overall brightness\/contrast). Brightness is\nintentionally left alone since it overlaps with Exposure. Give it a try\non a real photo and let me know how it feels.\n\n----------------------------------------------------------------------\nROUND 4 OF FEEDBACK\n----------------------------------------------------------------------\n\nUser: I don't like it. revert to the previous version, and disable the\nauto button for the tools that don't support it. Also, on the crop\ntool, can we set it to clip to the bounding box of the photo and make it\nmovable.\n\nClaude: Let's tackle these one at a time. First, reverting the Auto\nbutton to only Exposure\/Contrast.\n\n&#91;Claude reverted the Auto button to its earlier, simpler behavior\n(Exposure and Contrast only), and made it disable itself whenever the\nuser has a Light-tab slider selected that Auto doesn't touch (Brightness,\nHighlights, Shadows) \u2014 enabled only for Exposure and Contrast.]\n\nClaude: Now let's tackle the crop tool redesign \u2014 clamping to bounds and\nmaking the selection movable.\n\n&#91;Claude rewrote the crop tool's internals: the selection rectangle is\nnow a real item on the canvas (with a dimming overlay outside it, in the\nstyle of the Samsung editor) instead of a transient rubber band. While\ndrawing, the rectangle is clipped in real time to the photo's bounding\nbox \u2014 even with a locked aspect ratio, it shrinks to fit rather than\nbeing cut off-ratio at the edge. Once drawn, clicking inside the\nrectangle and dragging now moves it, constrained so it can't be dragged\npast the photo's edges. Claude wrote and ran automated tests for the\naspect-locked clipping, the move-and-clamp behavior, and the Auto\nbutton's enable\/disable logic before rebuilding.]\n\n&#91;Claude rebuilt, repackaged, and relaunched the app.]\n\nClaude: The rebuilt app is running with all three changes:\n\n1. Auto button reverted \u2014 back to only adjusting Exposure and Contrast\n   (no more Highlights\/Shadows).\n2. Auto button now disables itself when a Light-tab slider it doesn't\n   support is selected (Brightness, Highlights, Shadows) \u2014 it only stays\n   enabled for Exposure and Contrast.\n3. Crop tool overhauled \u2014 the selection is now clipped to the photo's\n   bounding box in real time as you draw (even with a locked aspect\n   ratio, it shrinks to fit rather than getting cut off-ratio at the\n   edges), and you can now click inside an existing crop rectangle and\n   drag to move it around, likewise constrained so it can't be dragged\n   past the photo's edges. There's also a dark overlay outside the\n   selection now, similar to the Samsung editor style.\n\nGive it a try and let me know how it feels.\n\n----------------------------------------------------------------------\nWHERE THINGS STAND\n----------------------------------------------------------------------\n\nThe app \u2014 Galaxy RAW Editor \u2014 is a PySide6 desktop RAW photo editor for\nLinux with:\n\n- A dark, One-UI-inspired interface: top bar (Open File \/ filename \/\n  Save Copy), a zoomable and pannable canvas, a slider control area, and\n  a bottom tab bar (Light, Color, Detail, Curves, Transform).\n- RAW decoding via rawpy\/LibRaw (.ORF, .CR2, .NEF, .ARW, .DNG, and more)\n  plus standard JPEG\/PNG support, case-insensitive on the Open dialog.\n- Light tab: Exposure, Brightness, Contrast, Highlights, Shadows, plus a\n  one-tap Auto button (Exposure\/Contrast only, disabled on sliders it\n  doesn't touch).\n- Color tab: Temperature, Tint, Saturation, Vibrance.\n- Detail tab: Sharpening, Clarity, Denoise.\n- Curves tab: a draggable Catmull-Rom tone curve.\n- Transform tab: Crop (with Free\/1:1\/4:3\/3:2\/16:9 presets, a\n  Landscape\/Portrait orientation toggle, bounding-box clamping, a\n  movable selection, and Cancel\/Apply), Rotate 90\u00b0, Flip Horizontal,\n  Flip Vertical.\n- Fast preview-resolution processing while dragging sliders, full-\n  resolution processing on export.\n- Packaged as a single portable GalaxyRawEditor-x86_64.AppImage via\n  PyInstaller + appimagetool.\n<\/code><\/pre>","protected":false},"excerpt":{"rendered":"<p>I am a photographer. As a photographer I shoot my images in RAW format. This gives me the greatest flexibility for post processing. But I hate the RAW photo editors, such as Darktable and RawTherapee. Don&#8217;t get me wrong, those are great tools, but I often just want to do some simple quick editing. Those &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/robertjwallace.com\/es\/wow-ai-coding-is-here\/\" class=\"more-link\">Continuar leyendo<span class=\"screen-reader-text\"> &#8220;Wow!  AI coding is here!&#8221;<\/span><\/a><\/p>","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"nf_dc_page":"","_eb_attr":"","footnotes":""},"categories":[171,143,8],"tags":[],"class_list":["post-8214","post","type-post","status-publish","format-standard","hentry","category-ai","category-computer-stuff","category-photography"],"featured_image_src":null,"featured_image_src_square":null,"author_info":{"display_name":"Bob","author_link":"https:\/\/robertjwallace.com\/es\/author\/admin\/"},"_links":{"self":[{"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/posts\/8214","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/comments?post=8214"}],"version-history":[{"count":3,"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/posts\/8214\/revisions"}],"predecessor-version":[{"id":8230,"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/posts\/8214\/revisions\/8230"}],"wp:attachment":[{"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/media?parent=8214"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/categories?post=8214"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/robertjwallace.com\/es\/wp-json\/wp\/v2\/tags?post=8214"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}