diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d55e3e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: python -m pip install -e ".[dev]" + - run: ruff check . + - run: pytest + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.9.0 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm build diff --git a/.gitignore b/.gitignore index 9feadc6..58cea78 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ __pycache__/ htmlcov/ build/ dist/ +web/node_modules/ +web/dist/ +.pnpm-store/ *.egg-info/ .reeftone_cache/ exports/ @@ -21,8 +24,30 @@ exports/ *.jpg *.JPEG *.jpeg +*.PNG +*.png +*.TIF +*.tif +*.TIFF +*.tiff +*.WEBP +*.webp +*.AVIF +*.avif +*.DNG +*.dng +*.XMP +*.xmp *.MOV *.mov *.MP4 *.mp4 +*.M4V +*.m4v +*.AVI +*.avi +*.MKV +*.mkv +# Public application artwork is reviewed and checked in deliberately. +!web/public/*.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 6896f0a..4246289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,102 @@ small refinements. ## [Unreleased] +### Added + +- Separate browser-local PWA alpha in `web/` +- Reliable device-local Canvas photo preview +- On-device JPEG/PNG opening, adjustments, comparison, and full-resolution export +- Browser capability reporting for GPU, Display P3, HDR display, OffscreenCanvas, + and WebAssembly support +- Automated web color-math tests and production build validation +- Web versions of the five curated looks and the full Light, Auto Restore, + White Balance, and Presence control groups +- Per-setting reset and bypass, undo/redo, copied edits, neutral eyedropper, + swipe/full-image comparison, magnifier zoom, and drag-to-pan +- Browser-side underwater scene analysis and the shared Python settings contract +- Web parity with the canonical desktop typography, spacing, toolbar, inspector, + Levels graph, disabled states, and responsive breakpoints +- Browser Green balance and five-point RGB/Red/Green/Blue Levels in preview + and full-resolution worker export +- Browser thresholded unsharp-mask Sharpening with Amount, Radius, Threshold, + reset/bypass, undo/redo, copied edits, and worker-export parity + +### Fixed + +- Hosted assets now use the deployment platform's expected client bundle +- The corrected preview no longer uses the silent-failing WebGPU shader path; + a reliable local Canvas renderer prevents black after-images +- Navigation now checks the network before an offline cache to prevent stale + alpha releases from remaining visible after deployment +- Web magnifier focal zoom now follows the desktop 25-percentage-point step +- Unsupported web formats and color workflows remain visible in their desktop + locations but are explicitly disabled instead of appearing functional +- Web balance labels now consistently read Red balance, Green balance, and Blue + balance while preserving their serialized keys +- Web typography, icons, control geometry, stacking, and responsive breakpoints + now consume the canonical desktop 1.5× stylesheet directly +- Whole-image Before/After is now the default web comparison mode; Swipe is an + explicit accessible opt-in +- Loaded photos now remain sticky beneath the web app header at mobile widths, + keeping the preview visible while adjustment controls scroll +- Mobile web editing now removes the floating Color information card and + low-priority preview metadata, keeps Before/After visible beside the looks, + reduces header space, and gives the photo a substantially larger sticky area +- Mobile adjustment labels, values, reset, and bypass actions now share one row + above each slider; section/control spacing and top look boxes are moderately + tighter while desktop geometry remains unchanged +- Mobile adjustment values now sit at the true center above their sliders, + independent of the right-aligned reset and bypass actions +- Web Sharpening now uses the standard Presence heading hierarchy without a + special card border, background, or inset padding +- Desktop web editing now keeps the image workspace sticky on the left while + the complete settings panel occupies and scrolls in the right column; the + mobile layout is unchanged + ### Planned - Native HDR gain-map editing and reconstruction - Transfer-function-aware HLG and PQ processing - Temporally stable HEVC video correction -- Hosted web edition after the local workflow is validated +- Shared color-managed WebAssembly core for Python/web algorithm parity + +## [0.6.0] - 2026-07-28 + +### Added + +- Output-neutral, float32 unsharp-mask Sharpening in Presence with Amount, + Radius, and Threshold controls +- Per-control and group reset/bypass, undo/redo, copied-settings, preview, API, + session, and full-resolution export support for Sharpening +- Sharpening coverage for neutral output, radius behavior, threshold noise + suppression, finite edge enhancement, clipping safety, and legacy settings + +### Changed + +- Visible underwater white-balance controls are now consistently named + Red balance, Green balance, and Blue balance while their serialized keys and + established adaptive processing remain backward compatible +- All desktop text and icon sizes, related control geometry, panel sizing, and + responsive breakpoints now resolve at exactly 1.5× the previous canonical scale +- Whole-image Before/After is now the default comparison mode when a photo opens + or adjustments reset; Swipe is an explicit opt-in and remains hidden until used +- The desktop UI/settings contract now records the exact 0.6.0 reference required + for the subsequent web parity pass + +## [0.5.0] - 2026-07-28 + +### Added + +- Neutral-by-default Green correction beside Red recovery and Blue balance +- Five-point monotonic Levels for RGB, Red, Green, and Blue, with a histogram, + channel reset/bypass, keyboard control, undo/redo, copied edits, and export parity +- A checked-in desktop UI/settings contract as the reference for web parity +- Representative identity, monotonicity, channel-isolation, and non-black + image-math coverage + +### Changed + +- Point-centered magnifier steps now move in 25% increments ## [0.4.1] - 2026-07-26 @@ -79,7 +169,9 @@ small refinements. - Local FastAPI service and responsive editor - Non-destructive sessions, presets, undo/redo, and before/after comparison -[Unreleased]: https://github.com/TomerSY/ReefTone/compare/v0.4.1...HEAD +[Unreleased]: https://github.com/TomerSY/ReefTone/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/TomerSY/ReefTone/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/TomerSY/ReefTone/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/TomerSY/ReefTone/releases/tag/v0.4.1 [0.4.0]: https://github.com/TomerSY/ReefTone/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/TomerSY/ReefTone/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index b852607..4bb5da1 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ like a focused desktop editor today and can grow into a hosted product later. - base bit-depth, ICC/P3, transfer-function, HDR gain-map, and auxiliary-image detection - float32 processing from decode through correction - scene analysis for red attenuation, cyan cast, haze, and low light -- underwater red compensation and confidence-gated white balance +- underwater-aware Red, Green, and Blue balance with confidence-gated white balance - research-backed multiscale fusion of color-balanced and contrast-enhanced inputs -- exposure, contrast, black/white points, tone, vibrance, clarity, and denoise +- exposure, contrast, five-point RGB/channel Levels, tone, vibrance, clarity, + thresholded unsharp-mask sharpening, and denoise - a neutral-point eyedropper plus per-slider reset and non-destructive bypass - a real-time before/after editor with distinct presets, undo/redo, drag-and-drop, and keyboard-accessible controls -- point-centered magnifier zoom, fixed pixel-ratio views, and drag-to-pan +- point-centered 25% magnifier zoom, fixed pixel-ratio views, and drag-to-pan - collapsible source color-space and dynamic-range information - full-resolution JPEG, PNG, and 16-bit TIFF export - 10-bit HEIC export with source ICC, EXIF, and available XMP preservation @@ -56,11 +57,16 @@ screen. You can also drag a file anywhere over the app. 1. Open a photo and start with **Natural**. Use **Dramatic** for a warmer, darker, high-contrast treatment. -2. Use the comparison divider to check skin, coral, and open-water gradients. -3. Adjust **Red recovery** before adding saturation. This restores missing balance - more naturally than globally boosting color. -4. Use **Fusion clarity** to blend in the multiscale contrast branch. -5. Export a JPEG for sharing or a 16-bit TIFF as a high-quality editing master. +2. Hold **Before** for a whole-image check, or explicitly enable **Swipe** for a + draggable divider. +3. Adjust **Red balance** before adding saturation. It is an underwater-aware + recovery control, not a naive red-channel multiplier. +4. Use **Green balance** when green dominates after Red balance, then refine tonal + placement with the five Levels markers. **Blue balance** handles blue dominance. +5. Use **Fusion clarity** to blend in the multiscale contrast branch. +6. Add **Sharpening** only when needed: Amount controls strength, Radius controls + the edge scale in pixels, and Threshold protects fine noise. +7. Export a JPEG for sharing or a 16-bit TIFF as a high-quality editing master. Use the eyedropper only on something that should be gray, white, or neutral. ReefTone averages a small patch and balances it without changing any other slider. @@ -84,6 +90,7 @@ src/reeftone/ app.py Local FastAPI application session.py Bounded, private preview sessions static/ Responsive editor UI +web/ Browser-local PWA alpha tests/ Unit and API tests docs/ Architecture and roadmap ``` @@ -118,12 +125,33 @@ JPEG photographs rather than camera RAW development. See [the algorithm notes](docs/algorithm.md) for details and limitations. The measurements behind the Dramatic look are recorded in [the reference-style analysis](docs/reference-style-analysis.md). +The exact desktop labels, layout tokens, and serialized setting behavior are the +reference for web parity in [the desktop UI contract](docs/desktop-ui-contract.md). Apple gain-map HDR is now detected and reported separately from the SDR base. ReefTone does not copy an unchanged gain map onto edited pixels or falsely tag an SDR result as HDR. The exact preservation matrix and native HDR roadmap are in [the color-management notes](docs/color-management.md). +## Browser-local web alpha + +The new [`web/`](web/) workspace is the beginning of a free public edition that +does its image work on the user's device. The first alpha opens JPEG/PNG files, +renders a reliable local Canvas preview, and exports locally. It has no photo upload +endpoint and does not change the Python editor. + +This alpha is a product and performance foundation, not yet a color-management +replacement for the Python app. HEIC, embedded metadata preservation, Display P3, +HDR output, algorithm parity, and video remain disabled until they can be +validated end to end. See the [web alpha notes](web/README.md) for its exact scope. + +```bash +cd web +pnpm install +pnpm test +pnpm dev +``` + ## Video roadmap The correction function already accepts a single RGB array with no UI or file-system diff --git a/docs/algorithm.md b/docs/algorithm.md index 8df15d0..355fc66 100644 --- a/docs/algorithm.md +++ b/docs/algorithm.md @@ -3,7 +3,7 @@ ## Design boundary Underwater restoration is ill-posed: attenuation varies with wavelength, range, -water type, illumination, and camera response. ReefTone 0.3 uses a deterministic +water type, illumination, and camera response. ReefTone uses a deterministic single-image enhancement pipeline. It does not claim to recover physically measured surface colors without range data. @@ -35,7 +35,7 @@ Decode + orient → RGB float32 ↓ Robust scene analysis ↓ -Red compensation + adaptive gray-world balance +Underwater-aware Red + Green + Blue balance and adaptive gray-world balance ↓ Optional user-sampled neutral-point gains ↓ @@ -44,7 +44,9 @@ Contrast/CLAHE branch ─┼→ Gaussian/Laplacian pyramid fusion ↓ ┘ Exposure → black/white points → tone curve ↓ -Color → clarity/denoise → non-destructive master mix +RGB Levels → Red → Green → Blue Levels + ↓ +Color → clarity/denoise → thresholded sharpening → non-destructive master mix ``` The same `correct_image()` function is used for preview, full-resolution export, @@ -59,7 +61,7 @@ cast, low dynamic range, low light, and underwater confidence. ### 2. Underwater white balance -Red recovery follows the Ancuti-style compensation shape: +Red balance retains the established Ancuti-style compensation shape: ```text R' = R + α(Ḡ - R̄)(1 - R)G @@ -71,7 +73,11 @@ magenta. After compensation, robust channel means are measured again and a confidence-weighted gray-world gain brings red closer to green without forcing the entire water column to neutral gray. -Temperature and tint are direct creative gains and remain neutral at zero. +Green balance is a scene-relative artist control. Positive values move a +green-heavy channel toward the mean of red and blue; negative values can restore +green when it is deficient. Blue balance remains scene-adaptive, and Temperature +and Tint remain direct creative gains. The serialized keys remain +`red_recovery`, `green_correction`, and `blue_balance` for backward compatibility. ### 3. Neutral-point eyedropper @@ -99,20 +105,31 @@ controls how strongly the fused result replaces the balanced base. - shadows and highlights use luminance masks; - black point and white point remap the endpoints before contrast; - contrast uses an exponential mid-gray pivot so small changes are visible; +- Levels uses five fixed input anchors and monotone cubic interpolation, applying + RGB before the Red, Green, and Blue channel curves; - vibrance preferentially affects low-saturation colors; - clarity is a mid-frequency unsharp mask; -- denoise is an edge-preserving bilateral blend. +- denoise is an edge-preserving bilateral blend; +- Sharpening uses a final float32 unsharp mask. Amount is 0–200%, Radius is + 0.3–5.0 pixels, and Threshold is a 0–20% luminance-difference gate that suppresses + small noise before detail is added. Amount defaults to zero, so legacy settings + and every curated preset remain output-neutral. ### 6. Non-destructive controls Every slider has two local actions: -- reset sets only that setting to its neutral value of zero; -- bypass sends zero for that setting while preserving its chosen value. +- reset sets only that setting to its documented default; +- bypass sends its documented neutral value while preserving its chosen value. Preset values are intentionally separated, and the master mix blends the entire corrected result with the untouched source. +Levels has the same behavior at channel scope. Reset restores the identity +`0, 0.25, 0.5, 0.75, 1` curve, while bypass temporarily substitutes that identity +without discarding the edited markers. Marker ordering is enforced in the UI and +again when API settings are parsed. + ## Precision and limitations All processing arithmetic is float32. Export quantizes only in the encoder: JPEG diff --git a/docs/color-management.md b/docs/color-management.md index 74449e3..260e1d9 100644 --- a/docs/color-management.md +++ b/docs/color-management.md @@ -2,10 +2,10 @@ ## Bottom line -ReefTone never overwrites the original. Version 0.4 detects the image's base bit -depth, embedded ICC profile or NCLX color tags, transfer function, HDR form, and -HEIF auxiliary images. Preview JPEGs now carry the source ICC profile so a -color-managed browser does not reinterpret Display P3 numbers as untagged sRGB. +ReefTone never overwrites the original. The current Python editor detects the +image's base bit depth, embedded ICC profile or NCLX color tags, transfer function, +HDR form, and HEIF auxiliary images. Preview JPEGs now carry the source ICC profile +so a color-managed browser does not reinterpret Display P3 numbers as untagged sRGB. For SDR and wide-color SDR images, edits remain in the source display-referred profile. Compatible exports preserve the ICC profile and camera metadata. @@ -16,7 +16,7 @@ the SDR and HDR versions. Until the native HDR pipeline described below is built ReefTone clearly labels the edit as using the color-managed SDR base and exports a wide-color SDR copy. -ISO HDR input is similarly detected from its bit depth and HLG/PQ tags. Version 0.4 +ISO HDR input is similarly detected from its bit depth and HLG/PQ tags. The editor asks libheif for a tone-mapped SDR decode before running the display-referred correction engine, tags that working copy as sRGB, and deliberately removes the HDR transfer tags from the edited output. This is safer than applying SDR math directly @@ -32,8 +32,8 @@ An iPhone HEIC is a container, not just one pixel array. A typical file can cont - an ICC or NCLX/CICP color description; - EXIF, XMP, GPS, MakerNote, orientation, and capture metadata. -The sample `IMG_5502.HEIC` in this project contains an 8-bit Display P3 base plus -an Apple HDR gain map and three additional auxiliary image types. Reporting it only +Validation samples have included an 8-bit Display P3 base, an Apple HDR gain map, +and additional auxiliary image types in one container. Reporting such a file only as “8-bit HEIC” would therefore be incomplete. ## Research basis @@ -53,7 +53,7 @@ Apple emphasizes tagging images with the correct profile and converting rather t assigning a different profile. See [Get Started with Display P3](https://developer.apple.com/videos/play/wwdc2017/821/). -## Export behavior in 0.4 +## Current Python export behavior | Format | Precision | ICC profile | EXIF/XMP | HDR gain map | | --- | ---: | --- | --- | --- | diff --git a/docs/desktop-ui-contract.md b/docs/desktop-ui-contract.md new file mode 100644 index 0000000..7c25a2b --- /dev/null +++ b/docs/desktop-ui-contract.md @@ -0,0 +1,166 @@ +# Desktop UI and settings contract + +ReefTone Desktop 0.6.0 is the explicit visual and behavioral reference for the +next web parity pass. The web renderer may differ, but it must not rename, reorder, +rescale, or silently reinterpret these controls. Serialized legacy keys remain +stable even where the visible label is clearer. + +## Visible order, labels, and help + +The top bar is ordered `ReefTone Studio`, document name/status, `Local & private`, +`Undo`, `Redo`, `Export`. The preview toolbar is ordered: + +1. Looks: `Natural`, `Vivid`, `Deep water`, `Gentle`, `Dramatic`. +2. `Color information`. +3. Comparison: `Swipe`, `Before`. +4. Zoom: `Fit`, `1:2`, `1:1`, `Fill`, `2:1`, magnifier percentage. + +The inspector heading is `Adjustments` / `Color restoration`, with `Copy settings` +then `Reset`. Its sections and controls are: + +1. **Light** — `Exposure`, `Contrast`, `Black point`, `White point`, `Highlights`, + `Shadows`. +2. **Levels** — channel dropdown `RGB`, `Red`, `Green`, `Blue`; group reset; group + bypass; one graph with markers `Black`, `Shadows`, `Midtone`, `Highlights`, + `White`. +3. **Auto Restore** — on/off switch, `Strength`, `Overall mix`. +4. **White balance** — `Sample neutral`, `Red balance`, `Green balance`, + `Blue balance`, `Fusion clarity`, `Temperature`, `Tint`. +5. **Presence** — `Saturation`, `Vibrance`, `Clarity`; compact **Sharpening** + subgroup with group reset/bypass and `Amount`, `Radius`, `Threshold`; `Denoise`. + +The White balance description and accessible help is: “Underwater-aware balance +adapts to wavelength loss; these are not simple RGB multipliers.” Each balance +slider exposes its visible name as its accessible label and a channel-specific +tooltip. This wording is intentional: the engine uses measured scene attenuation +and established adaptive compensation, not independent RGB multiplication. + +Sharpening tooltips define Amount as edge-detail strength, Radius as the blur radius +in pixels used to find edges, and Threshold as the minimum luminance difference +sharpened so higher values protect fine noise. + +Every ordinary slider has reset then bypass actions. Reset writes the documented +default. Bypass preserves the stored value, visually dims the affected control, and +sends its neutral value to processing; editing a bypassed slider enables it again. +Levels and Sharpening also have group reset/bypass. Copied settings include all +balance, Levels, and Sharpening fields, but exclude sampled neutral RGB values. +Export and preview send the same effective settings. + +## Comparison and zoom + +Opening a photo, opening another photo, or resetting establishes whole-image +Before/After mode: + +- `Swipe` is off (`aria-pressed="false"`). +- The comparison line, handle, and Before/After overlay labels are hidden. +- `Before` is enabled, shows the corrected whole image initially, and toggles the + whole image to original while active (`aria-pressed="true"`, visible text + changes to `After`). +- Enabling Swipe turns whole-image Before off, disables its button, reveals the + divider at 50%, and preserves mutual exclusivity. +- Holding `\` momentarily shows the original in either mode and restores the active + comparison state on release. + +The magnifier is point-centered. Left-click increases zoom by 25 percentage points; +right-click decreases it by 25 percentage points. Zoom is clamped to 10–1600%. + +## Settings schema + +All values are JSON numbers. Visible naming changed without migrating the three +stable balance keys. + +| Visible label | Key | Range | Default | Bypass | +| --- | --- | ---: | ---: | ---: | +| Overall mix | `master` | 0–1 | 1 | 0 | +| Strength | `auto_restore` | 0–1 | 0.90 | 0 | +| Red balance | `red_recovery` | 0–1.5 | 0.95 | 0 | +| Green balance | `green_correction` | −1–1 | 0 | 0 | +| Blue balance | `blue_balance` | −1–1 | 0.24 | 0 | +| Fusion clarity | `dehaze` | 0–1 | 0.38 | 0 | +| Temperature | `temperature` | −1–1 | 0.04 | 0 | +| Tint | `tint` | −1–1 | 0.01 | 0 | +| Exposure | `exposure` | −2–2 | 0 | 0 | +| Contrast | `contrast` | −0.5–0.8 | 0.22 | 0 | +| Black point | `black_point` | −0.5–0.5 | 0.08 | 0 | +| White point | `white_point` | −0.5–0.5 | 0.04 | 0 | +| Highlights | `highlights` | −1–1 | −0.14 | 0 | +| Shadows | `shadows` | −1–1 | 0.10 | 0 | +| Saturation | `saturation` | −1–1 | 0.05 | 0 | +| Vibrance | `vibrance` | −1–1 | 0.22 | 0 | +| Clarity | `clarity` | −1–1 | 0.16 | 0 | +| Sharpening Amount | `sharpen_amount` | 0–2 (0–200%) | 0 | 0 | +| Sharpening Radius | `sharpen_radius` | 0.3–5 px | 1 px | 1 px | +| Sharpening Threshold | `sharpen_threshold` | 0–0.2 (0–20%) | 0.02 | 0 | +| Denoise | `denoise` | 0–1 | 0.03 | 0 | + +Levels use +`levels_{rgb|red|green|blue}_{black|shadows|midtone|highlights|white}`. Each value +ranges from 0 to 1; defaults in visible order are `0, 0.25, 0.5, 0.75, 1`. +The neutral-point fields are `sample_red`, `sample_green`, `sample_blue`, and +`sample_strength`; each ranges from 0 to 1 and defaults to zero. + +All curated presets intentionally retain neutral Green balance, identity Levels, +and Sharpening Amount 0. Old serialized settings omit Sharpening and therefore +deserialize to Amount 0, Radius 1 px, Threshold 0.02 without changing output. + +## Five-point Levels behavior + +The five names represent fixed input anchors at `0, 0.25, 0.5, 0.75, 1`; dragging +a marker changes that anchor's output value. Output values are non-decreasing. A +marker is clamped between its immediate neighbors and cannot cross them. Keyboard +arrows move a focused marker by 0.01; Shift plus an arrow moves it by 0.05. Pointer +dragging is one undo step. + +Processing uses float32 monotone cubic Hermite interpolation without overshoot. +The RGB curve applies to all channels first, followed by Red, Green, and Blue. +Bypass substitutes identity values while retaining the edited curve. Preview and +full-resolution export use the same code. + +## Sharpening behavior + +Sharpening runs in float32 after color, clarity, and denoise, and before the master +mix. A Gaussian blur with `sigma = Radius` defines the unsharp detail. Threshold +uses the absolute luma of that detail and a short feathered gate before Amount is +applied. Output is finite and clamped once to the editor’s `[0, 1]` display-referred +working range. Amount 0 returns the input unchanged and does not run the blur. + +Per-control edits, reset, bypass, group reset, and group bypass are undoable. +Group reset restores `0 / 1 px / 0.02`; group bypass neutralizes Amount only while +preserving all three stored values. Editing any Sharpening slider re-enables the +group. + +## Canonical visual tokens and responsive behavior + +The 0.6.0 scale token is `--ui-scale: 1.5`. Every text size and icon/glyph dimension +is exactly 1.5× the prior canonical base value; CSS retains the base value beside +the multiplier so web parity can reproduce it without rounding. + +- Font stack: `Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif`. +- Resolved top bar: 96 px (64 × 1.5). Desktop inspector: 516 px (344 × 1.5). + Inspector horizontal padding: 24 px; section vertical padding: 25.5 px; slider + list gap: 24 px. +- Resolved type: brand 22.5 px; inspector title 25.5 px; section title 18 px; + slider label/value 15 px; section description 13.5 px; Levels marker labels + 10.5 px. Body copy, buttons, tabs, badges, tooltips, status text, and dialog text + use the same exact 1.5 multiplier over their checked-in base values. +- Resolved common glyphs: top icon 25.5 px; primary-button icon 24 px; section + chevron 24 px; mini-control glyph 18 px; slider thumb 18 px; comparison handle + 48 px with a 24 px glyph. +- Levels graph: full inspector width, 9:4 aspect ratio, resolved 12 px radius, + 288 × 128 internal coordinate space. Its SVG scales with the 1.5× inspector, + including its visible five markers. +- Primary colors: background `#07100f`, surface `#0d1816`, text `#edf7f2`, muted + `#8fa8a0`, accent `#6ce0c4`, strong accent `#9af4de`. +- Shared radius base token remains 14 px. Standard control/card radii and all + fixed control heights/spacing resolve at the 1.5 multiplier. +- To prevent the 1.5× Looks and comparison toolbars from colliding, the canvas and + 516 px reference-width inspector stack at 1800 px and below. At the exact 1.5× + mobile breakpoint (1035 px from the former 690 px), the top bar becomes 87 px, + document/undo/redo and right preview tools hide, and the inspector fills the + available width. + +Before an image is open, the preview toolbar is hidden and Export, Copy settings, +Reset, Undo, and Redo are disabled with 0.35 opacity and a not-allowed cursor. +Adjustment controls remain visible; opening an image always reapplies Natural. +Undo and Redo enable only when their respective stacks contain an edit. diff --git a/docs/reference-style-analysis.md b/docs/reference-style-analysis.md index 02fa148..ca728df 100644 --- a/docs/reference-style-analysis.md +++ b/docs/reference-style-analysis.md @@ -1,7 +1,7 @@ # Reference style analysis -This analysis compares the supplied “before” screenshot with the generated -ChatGPT “after” screenshot. It informed ReefTone's **Dramatic** preset. +This analysis compares a supplied “before” screenshot with an AI-generated +“after” reference. It informed ReefTone's **Dramatic** preset. ## Important limitation @@ -41,21 +41,22 @@ Mean RGB values show the color strategy: ## Reusable reconstruction -The closest non-generative reconstruction is a layered pipeline: +The reference suggests this layered, non-generative reconstruction: 1. Moderate adaptive white balance—avoid pushing red globally. 2. A stronger S-curve with highlight protection. 3. Selective aqua/blue mixing that keeps water luminous. 4. A soft subject-attention mask for lift, warmth, and texture. 5. A darker, slightly warmer background outside that mask. -6. A feathered top gradient and optical vignette in the generated reference. - -ReefTone 0.3 intentionally does not expose guessed depth, subject-focus, gradient, -or vignette controls. The reference remains useful for global color and tone -targets, while the production algorithm now uses depth-free white balance and -multiscale fusion. -7. Targeted texture/clarity, with conservative denoise. - -These behaviors are exposed as independent controls. The **Dramatic** preset is a -starting point, and **Overall mix** can reduce its intensity without changing the -relative balance of the layers. +6. A feathered top gradient and optical vignette. +7. Targeted texture and clarity, with conservative denoise. + +The subject mask, top gradient, and vignette describe evidence in the reference; +they are not claims about ReefTone's current implementation. ReefTone intentionally +avoids guessed depth or focus controls. Its production pipeline uses depth-free +white balance and multiscale fusion, so the reference remains a color-and-tone +target rather than a pixel-matching specification. + +The implemented global behaviors are exposed as independent controls. The +**Dramatic** preset is a starting point, and **Overall mix** can reduce its intensity +without changing the relative balance of the active processing stages. diff --git a/docs/roadmap.md b/docs/roadmap.md index a3938bc..22bddd0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -## Phase 1 — still-image foundation (current) +## Phase 1 — still-image foundation (complete) - adaptive correction core - HEIC/JPEG/PNG/TIFF input @@ -10,25 +10,61 @@ - full-resolution export - automated tests and sample validation -## Phase 2 — quality validation +## Phase 2 — browser-local public alpha (current) + +- static PWA with no image upload endpoint +- local JPEG/PNG decode, reliable Canvas preview, and worker-based export +- desktop-matched 1.5× web interface plus Green balance, five-point Levels, and + output-neutral thresholded Sharpening +- responsive desktop and mobile editing workspace +- browser capability detection and explicit format limitations +- shared settings contract and cross-runtime reference fixtures +- color-managed WebAssembly processing core +- HEIC/HEIF decode with verified ICC and metadata behavior +- safe Display P3 preview and export + +## Phase 3 — quality, native HDR, and still-image HDR expansion - visual reference set across depths, water colors, cameras, and artificial lights - perceptual and no-reference quality metrics - selectable subject masks for divers, reef, and open water +- stream and image inspection that identifies actual bit depth, transfer function, + primaries, matrix, range, gain maps, and HDR metadata instead of inferring them + from a filename or container extension - native gain-map/ISO-HDR expansion into an unclamped linear working space - HDR-safe correction operators and new 10-bit HDR HEIF generation - true deep-color decode-path validation across HEIC producers +- optional SDR-to-HDR expansion for 8-bit photographs, using conservative + highlight-aware inverse tone mapping with clipping, noise, and banding protection +- adaptive gain-map JPEG output with a carefully graded SDR base, plus validated + ISO 21496-1, HEIF, and AVIF HDR paths where platform support permits +- explicit `SDR source / HDR expanded` labeling so creative expansion is never + presented as recovered native HDR capture +- HDR and SDR-fallback validation on Instagram, iOS, Android, and common browsers -## Phase 3 — 10-bit video +## Phase 4 — video and SDR-to-HDR expansion - FFmpeg/PyAV ingestion for HEVC, H.264, ProRes, HDR10, and HLG +- codec-level probing of MP4 and other containers, including pixel format and + bit depth, rather than treating `.mp4` as evidence of SDR or HDR - temporal gain stabilization and scene-cut handling +- optional temporally stable expansion of 8-bit Rec.709 SDR footage into an HDR + working timeline without claiming to restore clipped or unrecorded detail +- highlight-confidence controls, chroma-noise protection, debanding, and dithering + designed for compressed action-camera and underwater footage - proxy generation for responsive preview - range, primaries, matrix, and transfer metadata preservation +- 10-bit HEVC Main10 HLG/PQ output with correct Rec.2020 signaling, an SDR preview, + and a tone-mapped fallback rendition +- short-form social export validation, including Instagram Reels on HDR and SDR + playback devices - hardware encode where available - frame queue with cancellation and progress reporting -## Phase 4 — public web product +## Phase 5 — optional cloud services + +The core public editor remains browser-local and free. Cloud features are optional +and only added where they provide value that cannot be delivered safely on-device. - isolated object storage with short retention - queued GPU/CPU jobs and resumable uploads diff --git a/pyproject.toml b/pyproject.toml index f5a9656..0130e43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "reeftone" -version = "0.4.1" +version = "0.6.0" description = "A non-destructive, adaptive underwater color-correction studio." readme = "README.md" requires-python = ">=3.11" diff --git a/src/reeftone/__init__.py b/src/reeftone/__init__.py index befacb1..7a9db34 100644 --- a/src/reeftone/__init__.py +++ b/src/reeftone/__init__.py @@ -4,4 +4,4 @@ from .processor import analyze_image, correct_image __all__ = ["CorrectionSettings", "analyze_image", "correct_image"] -__version__ = "0.4.1" +__version__ = "0.6.0" diff --git a/src/reeftone/app.py b/src/reeftone/app.py index 258d3eb..53ba955 100644 --- a/src/reeftone/app.py +++ b/src/reeftone/app.py @@ -32,7 +32,7 @@ app = FastAPI( title="ReefTone", - version="0.4.1", + version="0.6.0", description="Adaptive underwater color restoration", ) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/src/reeftone/config.py b/src/reeftone/config.py index 1832bcb..3306bc0 100644 --- a/src/reeftone/config.py +++ b/src/reeftone/config.py @@ -5,6 +5,10 @@ from dataclasses import asdict, dataclass, fields from typing import Any +LEVEL_CHANNELS = ("rgb", "red", "green", "blue") +LEVEL_POINTS = ("black", "shadows", "midtone", "highlights", "white") +LEVEL_DEFAULTS = (0.0, 0.25, 0.5, 0.75, 1.0) + @dataclass(slots=True) class CorrectionSettings: @@ -13,6 +17,7 @@ class CorrectionSettings: master: float = 1.0 auto_restore: float = 0.90 red_recovery: float = 0.95 + green_correction: float = 0.0 blue_balance: float = 0.24 dehaze: float = 0.38 temperature: float = 0.04 @@ -27,15 +32,39 @@ class CorrectionSettings: vibrance: float = 0.22 clarity: float = 0.16 denoise: float = 0.03 + sharpen_amount: float = 0.0 + sharpen_radius: float = 1.0 + sharpen_threshold: float = 0.02 sample_red: float = 0.0 sample_green: float = 0.0 sample_blue: float = 0.0 sample_strength: float = 0.0 + levels_rgb_black: float = 0.0 + levels_rgb_shadows: float = 0.25 + levels_rgb_midtone: float = 0.5 + levels_rgb_highlights: float = 0.75 + levels_rgb_white: float = 1.0 + levels_red_black: float = 0.0 + levels_red_shadows: float = 0.25 + levels_red_midtone: float = 0.5 + levels_red_highlights: float = 0.75 + levels_red_white: float = 1.0 + levels_green_black: float = 0.0 + levels_green_shadows: float = 0.25 + levels_green_midtone: float = 0.5 + levels_green_highlights: float = 0.75 + levels_green_white: float = 1.0 + levels_blue_black: float = 0.0 + levels_blue_shadows: float = 0.25 + levels_blue_midtone: float = 0.5 + levels_blue_highlights: float = 0.75 + levels_blue_white: float = 1.0 RANGES = { "master": (0.0, 1.0), "auto_restore": (0.0, 1.0), "red_recovery": (0.0, 1.5), + "green_correction": (-1.0, 1.0), "blue_balance": (-1.0, 1.0), "dehaze": (0.0, 1.0), "temperature": (-1.0, 1.0), @@ -50,10 +79,18 @@ class CorrectionSettings: "vibrance": (-1.0, 1.0), "clarity": (-1.0, 1.0), "denoise": (0.0, 1.0), + "sharpen_amount": (0.0, 2.0), + "sharpen_radius": (0.3, 5.0), + "sharpen_threshold": (0.0, 0.2), "sample_red": (0.0, 1.0), "sample_green": (0.0, 1.0), "sample_blue": (0.0, 1.0), "sample_strength": (0.0, 1.0), + **{ + f"levels_{channel}_{point}": (0.0, 1.0) + for channel in LEVEL_CHANNELS + for point in LEVEL_POINTS + }, } @classmethod @@ -70,11 +107,29 @@ def from_mapping(cls, values: dict[str, Any] | None) -> CorrectionSettings: continue low, high = cls.RANGES[key] clean[key] = min(high, max(low, number)) - return cls(**clean) + settings = cls(**clean) + for channel in LEVEL_CHANNELS: + for point, value in zip(LEVEL_POINTS, settings.level_points(channel), strict=True): + setattr(settings, f"levels_{channel}_{point}", value) + return settings def to_dict(self) -> dict[str, float]: return asdict(self) + def level_points(self, channel: str) -> tuple[float, float, float, float, float]: + """Return a safe non-decreasing five-point curve for a supported channel.""" + + if channel not in LEVEL_CHANNELS: + raise ValueError(f"Unsupported levels channel: {channel}") + values: list[float] = [] + previous = 0.0 + for point in LEVEL_POINTS: + value = min(1.0, max(0.0, float(getattr(self, f"levels_{channel}_{point}")))) + value = max(previous, value) + values.append(value) + previous = value + return values[0], values[1], values[2], values[3], values[4] + PRESETS: dict[str, CorrectionSettings] = { "natural": CorrectionSettings(), diff --git a/src/reeftone/processor.py b/src/reeftone/processor.py index 99ae9b4..b2834cd 100644 --- a/src/reeftone/processor.py +++ b/src/reeftone/processor.py @@ -13,7 +13,7 @@ import numpy as np from numpy.typing import NDArray -from .config import CorrectionSettings +from .config import LEVEL_CHANNELS, LEVEL_DEFAULTS, CorrectionSettings FloatImage = NDArray[np.float32] @@ -160,6 +160,17 @@ def _adaptive_white_balance( * (0.35 + recovered[..., 1]) ) + # Positive Green balance moves a green-heavy scene toward the mean of the + # red and blue channels. Negative values deliberately add green when needed. + green_target = (red_mean + blue_mean) * 0.5 + green_gap = green_target - green_mean + recovered[..., 1] += ( + green_gap + * settings.green_correction + * (1.0 - recovered[..., 1]) + * (0.35 + recovered[..., 2]) + ) + # Re-measure before the gray-world stage. The target remains slightly cool by # design, but is much closer to neutral than the previous pale-cyan result. balanced_means, _, _ = _robust_channel_stats(np.clip(recovered, 0.0, 1.0)) @@ -289,6 +300,74 @@ def _apply_tone(image: FloatImage, settings: CorrectionSettings) -> FloatImage: return np.clip(result, 0.0, 1.0) +def _monotone_curve( + values: NDArray[np.float32], + points: tuple[float, float, float, float, float], +) -> NDArray[np.float32]: + """Evaluate a five-point monotone cubic Hermite curve without overshoot.""" + + if points == LEVEL_DEFAULTS: + return values.astype(np.float32, copy=False) + + controls = np.asarray(points, dtype=np.float32) + delta = np.diff(controls) * np.float32(4.0) + tangents = np.zeros(5, dtype=np.float32) + + for index in range(1, 4): + before = float(delta[index - 1]) + after = float(delta[index]) + if before > 0.0 and after > 0.0: + tangents[index] = np.float32(2.0 * before * after / (before + after)) + + first = np.float32((3.0 * float(delta[0]) - float(delta[1])) * 0.5) + if first * delta[0] <= 0.0: + first = np.float32(0.0) + elif abs(float(first)) > 3.0 * abs(float(delta[0])): + first = np.float32(3.0) * delta[0] + tangents[0] = first + + last = np.float32((3.0 * float(delta[3]) - float(delta[2])) * 0.5) + if last * delta[3] <= 0.0: + last = np.float32(0.0) + elif abs(float(last)) > 3.0 * abs(float(delta[3])): + last = np.float32(3.0) * delta[3] + tangents[4] = last + + clipped = np.clip(values, 0.0, 1.0) + scaled = clipped * np.float32(4.0) + segment = np.minimum(scaled.astype(np.intp), 3) + position = scaled - segment.astype(np.float32) + position2 = position * position + position3 = position2 * position + h00 = 2.0 * position3 - 3.0 * position2 + 1.0 + h10 = position3 - 2.0 * position2 + position + h01 = -2.0 * position3 + 3.0 * position2 + h11 = position3 - position2 + result = ( + h00 * controls[segment] + + h10 * tangents[segment] * np.float32(0.25) + + h01 * controls[segment + 1] + + h11 * tangents[segment + 1] * np.float32(0.25) + ) + return np.clip(result, 0.0, 1.0).astype(np.float32) + + +def _apply_levels(image: FloatImage, settings: CorrectionSettings) -> FloatImage: + """Apply the master RGB curve, then the Red, Green, and Blue curves.""" + + rgb_points = settings.level_points("rgb") + channel_points = [settings.level_points(channel) for channel in LEVEL_CHANNELS[1:]] + result = _monotone_curve(image, rgb_points) + if result is image and any(points != LEVEL_DEFAULTS for points in channel_points): + result = image.copy() + for index, points in enumerate(channel_points): + result[..., index] = _monotone_curve( + result[..., index], + points, + ) + return result + + def _apply_color(image: FloatImage, settings: CorrectionSettings) -> FloatImage: luma = _rgb_luma(image)[..., None] chroma = image - luma @@ -310,6 +389,33 @@ def _apply_detail(image: FloatImage, clarity: float, denoise: float) -> FloatIma return np.clip(result, 0.0, 1.0) +def _apply_sharpening( + image: FloatImage, + amount: float, + radius: float, + threshold: float, +) -> FloatImage: + """Apply thresholded unsharp masking in float precision.""" + + if amount <= 0.001: + return image + sigma = max(0.3, float(radius)) + blurred = cv2.GaussianBlur( + image, + (0, 0), + sigmaX=sigma, + sigmaY=sigma, + borderType=cv2.BORDER_REFLECT101, + ) + detail = image - blurred + if threshold > 0.0: + magnitude = np.abs(_rgb_luma(detail)) + feather = max(float(threshold) * 0.5, 1e-4) + gate = np.clip((magnitude - float(threshold)) / feather, 0.0, 1.0) + detail = detail * gate[..., None] + return np.clip(image + detail * np.float32(amount), 0.0, 1.0).astype(np.float32) + + def correct_image( image: NDArray[np.generic], settings: CorrectionSettings | None = None, @@ -324,8 +430,15 @@ def correct_image( fusion_amount = settings.dehaze * (0.62 + analysis.haze * 0.38) corrected = _multiscale_fusion(corrected, fusion_amount) corrected = _apply_tone(corrected, settings) + corrected = _apply_levels(corrected, settings) corrected = _apply_color(corrected, settings) corrected = _apply_detail(corrected, settings.clarity, settings.denoise) + corrected = _apply_sharpening( + corrected, + settings.sharpen_amount, + settings.sharpen_radius, + settings.sharpen_threshold, + ) master = np.float32(settings.master) return np.clip(original * (1.0 - master) + corrected * master, 0.0, 1.0).astype(np.float32) diff --git a/src/reeftone/static/app.js b/src/reeftone/static/app.js index d25169f..9881ab9 100644 --- a/src/reeftone/static/app.js +++ b/src/reeftone/static/app.js @@ -1,6 +1,19 @@ const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; -const APP_VERSION = "0.4.1"; +const APP_VERSION = "0.6.0"; +const LEVEL_CHANNELS = ["rgb", "red", "green", "blue"]; +const LEVEL_POINTS = ["black", "shadows", "midtone", "highlights", "white"]; +const LEVEL_DEFAULTS = [0, 0.25, 0.5, 0.75, 1]; +const LEVEL_VIEW = {width: 288, height: 128, padding: 12}; +const CONTROL_DEFAULTS = { + sharpen_amount: 0, + sharpen_radius: 1, + sharpen_threshold: 0.02, +}; +const CONTROL_BYPASS_VALUES = { + sharpen_radius: 1, + sharpen_threshold: 0, +}; const state = { session: null, @@ -10,7 +23,7 @@ const state = { history: [], future: [], compare: 50, - swipeEnabled: true, + swipeEnabled: false, fullBefore: false, zoomScale: 1, zoomMode: "fit", @@ -26,6 +39,7 @@ const state = { originalUrl: null, dragDepth: 0, sampling: false, + levelsChannel: "rgb", }; const elements = { @@ -59,6 +73,12 @@ const elements = { colorCard: $("#colorCard"), colorCardContent: $("#colorCardContent"), zoomPercent: $("#zoomPercent"), + levelsControl: $("#levelsControl"), + levelsChannel: $("#levelsChannel"), + levelsEditor: $("#levelsEditor"), + levelsCurve: $("#levelsCurve"), + levelsMarkers: $("#levelsMarkers"), + levelsHistogram: $("#levelsHistogram"), }; function formatBytes(bytes) { @@ -199,9 +219,10 @@ async function activateSession(session) { elements.original.src = state.originalUrl; elements.corrected.src = state.originalUrl; await elements.original.decode(); + drawLevelsHistogram(); scrollToTop(); alignImageLayers(); - setSwipeEnabled(true); + setSwipeEnabled(false); setColorInfoExpanded(false); setZoomTool(false); setZoomMode("fit"); @@ -351,7 +372,16 @@ function restoreSnapshot(snapshot) { function effectiveSettings() { const effective = structuredClone(state.settings); state.bypassed.forEach(name => { - if (Object.hasOwn(effective, name)) effective[name] = 0; + if (Object.hasOwn(effective, name)) { + effective[name] = CONTROL_BYPASS_VALUES[name] ?? 0; + } + }); + if (state.bypassed.has("sharpening")) effective.sharpen_amount = 0; + LEVEL_CHANNELS.forEach(channel => { + if (!state.bypassed.has(`levels_${channel}`)) return; + LEVEL_POINTS.forEach((point, index) => { + effective[`levels_${channel}_${point}`] = LEVEL_DEFAULTS[index]; + }); }); return effective; } @@ -391,6 +421,9 @@ function updateHistoryButtons() { function displayValue(name, value) { if (name === "exposure") return `${value > 0 ? "+" : ""}${value.toFixed(2)}`; + if (name === "sharpen_amount") return `${Math.round(value * 100)}%`; + if (name === "sharpen_radius") return `${value.toFixed(1)} px`; + if (name === "sharpen_threshold") return `${Math.round(value * 100)}%`; const number = Math.round(value * 100); if (["master", "auto_restore", "red_recovery", "dehaze", "denoise"].includes(name)) return String(number); return `${number > 0 ? "+" : number < 0 ? "−" : ""}${Math.abs(number)}`; @@ -415,12 +448,15 @@ function syncControls() { control?.classList.toggle("bypassed", bypassed); const bypassButton = control?.querySelector(".bypass-control"); if (bypassButton) { + const readable = input.dataset.label || name.replaceAll("_", " "); bypassButton.setAttribute("aria-pressed", String(!bypassed)); - bypassButton.title = bypassed ? `Enable ${name.replaceAll("_", " ")}` : `Temporarily disable ${name.replaceAll("_", " ")}`; + bypassButton.title = bypassed ? `Enable ${readable}` : `Temporarily disable ${readable}`; } }); $("#autoToggle").checked = state.settings.auto_restore > 0; syncSampleStatus(); + renderLevelsControl(); + syncSharpeningControl(); } function applyPreset(name, record = true) { @@ -455,11 +491,12 @@ function addPerControlActions() { const control = input.closest(".slider-control"); if (!control || control.querySelector(".control-actions")) return; const name = input.dataset.setting; - const readable = name.replaceAll("_", " "); + const readable = input.dataset.label || name.replaceAll("_", " "); + const defaultValue = CONTROL_DEFAULTS[name] ?? 0; const actions = document.createElement("span"); actions.className = "control-actions"; actions.innerHTML = ` - - +
- @@ -131,13 +131,13 @@

Bring the reef back to life.

Color-corrected underwater photo
-
+ - Before - After + +
Refining preview @@ -210,6 +210,40 @@

Color restoration

+
+
+

Levels

Five-point tonal curve

+
+ + + +
+
+
+ + + + + + +
+ +
+

Auto Restore

Scene-aware color recovery

@@ -229,7 +263,7 @@

Color restoration

-

White balance

Recover absorbed wavelengths

+

White balance

Underwater-aware balance adapts to wavelength loss; these are not simple RGB multipliers.

+ +
+
+ SharpeningThresholded unsharp mask + + + + +
+ + + +