focalpy 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. focalpy-0.0.1/.gitattributes +2 -0
  2. focalpy-0.0.1/.github/workflows/wheels.yml +69 -0
  3. focalpy-0.0.1/.gitignore +8 -0
  4. focalpy-0.0.1/CMakeLists.txt +48 -0
  5. focalpy-0.0.1/LICENSE +21 -0
  6. focalpy-0.0.1/PKG-INFO +95 -0
  7. focalpy-0.0.1/README.md +66 -0
  8. focalpy-0.0.1/include/focal/border.hpp +52 -0
  9. focalpy-0.0.1/include/focal/buffer.hpp +45 -0
  10. focalpy-0.0.1/include/focal/check.hpp +44 -0
  11. focalpy-0.0.1/include/focal/convert.hpp +54 -0
  12. focalpy-0.0.1/include/focal/io.hpp +31 -0
  13. focalpy-0.0.1/include/focal/ops/blur.hpp +20 -0
  14. focalpy-0.0.1/include/focal/ops/point.hpp +110 -0
  15. focalpy-0.0.1/include/focal/sample.hpp +28 -0
  16. focalpy-0.0.1/include/focal/srgb.hpp +21 -0
  17. focalpy-0.0.1/include/focal/version.hpp +8 -0
  18. focalpy-0.0.1/include/focal/view.hpp +133 -0
  19. focalpy-0.0.1/pyproject.toml +63 -0
  20. focalpy-0.0.1/python/bindings.cpp +238 -0
  21. focalpy-0.0.1/python/focal/__init__.py +35 -0
  22. focalpy-0.0.1/src/convert.cpp +45 -0
  23. focalpy-0.0.1/src/io.cpp +143 -0
  24. focalpy-0.0.1/src/ops/blur.cpp +66 -0
  25. focalpy-0.0.1/src/ops/point.cpp +89 -0
  26. focalpy-0.0.1/src/sample.cpp +37 -0
  27. focalpy-0.0.1/src/srgb.cpp +26 -0
  28. focalpy-0.0.1/src/version.cpp +7 -0
  29. focalpy-0.0.1/tests/python/test_blur.py +64 -0
  30. focalpy-0.0.1/tests/python/test_border.py +60 -0
  31. focalpy-0.0.1/tests/python/test_io.py +116 -0
  32. focalpy-0.0.1/tests/python/test_point.py +108 -0
  33. focalpy-0.0.1/tests/python/test_sample.py +40 -0
  34. focalpy-0.0.1/tests/python/test_srgb.py +32 -0
  35. focalpy-0.0.1/third_party/stb/stb_image.h +7988 -0
  36. focalpy-0.0.1/third_party/stb/stb_image_write.h +1724 -0
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1,69 @@
1
+ name: Wheels
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ build_wheels:
12
+ name: wheels on ${{ matrix.os }}
13
+ runs-on: ${{ matrix.os }}
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ # macos-14 is arm64 and cross-compiles the x86_64 wheel too (see
18
+ # [tool.cibuildwheel.macos] archs). GitHub retired the Intel macos-13
19
+ # runners, so requesting one queues forever.
20
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-14, windows-latest]
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - uses: actions/setup-python@v5
25
+ with:
26
+ python-version: "3.12"
27
+
28
+ # Pin this to an exact version once you have a build you trust.
29
+ - run: python -m pip install "cibuildwheel>=2.20"
30
+
31
+ # Config lives in [tool.cibuildwheel] in pyproject.toml. Because the
32
+ # extension is abi3, this builds cp312 only and the result covers every
33
+ # later Python.
34
+ - run: python -m cibuildwheel --output-dir wheelhouse
35
+
36
+ - uses: actions/upload-artifact@v4
37
+ with:
38
+ name: wheels-${{ matrix.os }}
39
+ path: wheelhouse/*.whl
40
+
41
+ make_sdist:
42
+ name: sdist
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - run: pipx run build --sdist
47
+ - uses: actions/upload-artifact@v4
48
+ with:
49
+ name: sdist
50
+ path: dist/*.tar.gz
51
+
52
+ publish:
53
+ name: publish to PyPI
54
+ needs: [build_wheels, make_sdist]
55
+ runs-on: ubuntu-latest
56
+ # Only on a version tag: git tag v0.0.2 && git push --tags
57
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
58
+ environment: pypi
59
+ permissions:
60
+ # Trusted publishing: PyPI verifies this workflow via OIDC, so there is
61
+ # no API token to store. Register the repo at
62
+ # https://pypi.org/manage/account/publishing/ first.
63
+ id-token: write
64
+ steps:
65
+ - uses: actions/download-artifact@v4
66
+ with:
67
+ path: dist
68
+ merge-multiple: true
69
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ build/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.so
6
+ .pytest_cache/
7
+ compile_commands.json
8
+ .DS_Store
@@ -0,0 +1,48 @@
1
+ cmake_minimum_required(VERSION 3.26)
2
+ project(focal LANGUAGES CXX VERSION 0.0.1)
3
+
4
+ set(CMAKE_CXX_STANDARD 20)
5
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
6
+ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
7
+ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # for clangd / IDE tooling
8
+
9
+ if(NOT CMAKE_BUILD_TYPE)
10
+ set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
11
+ endif()
12
+
13
+ # The C++ library. Globbed with CONFIGURE_DEPENDS so new files under src/
14
+ # get picked up without editing this file.
15
+ file(GLOB_RECURSE FOCAL_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
16
+ add_library(focal_core STATIC ${FOCAL_SOURCES})
17
+ target_include_directories(focal_core PUBLIC
18
+ "${CMAKE_CURRENT_SOURCE_DIR}/include"
19
+ "${CMAKE_CURRENT_SOURCE_DIR}/third_party")
20
+ target_compile_options(focal_core PRIVATE
21
+ $<$<CXX_COMPILER_ID:AppleClang,Clang,GNU>:-Wall -Wextra>)
22
+
23
+ if(MSVC)
24
+ # windows.h (pulled in by stb under STBI_WINDOWS_UTF8) defines min/max as
25
+ # macros, which breaks std::min and std::max at every call site. stb also
26
+ # uses fopen/sprintf, which MSVC flags as deprecated.
27
+ target_compile_definitions(focal_core PRIVATE NOMINMAX _CRT_SECURE_NO_WARNINGS)
28
+ endif()
29
+
30
+ # The Python extension module.
31
+ # Development.SABIModule is what nanobind links against for STABLE_ABI below;
32
+ # without it the STABLE_ABI flag is silently ignored and you get one wheel per
33
+ # Python version instead of one per platform.
34
+ find_package(Python 3.12 REQUIRED
35
+ COMPONENTS Interpreter Development.Module Development.SABIModule)
36
+
37
+ execute_process(
38
+ COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
39
+ OUTPUT_VARIABLE NANOBIND_CMAKE_DIR OUTPUT_STRIP_TRAILING_WHITESPACE
40
+ COMMAND_ERROR_IS_FATAL ANY)
41
+ list(APPEND CMAKE_PREFIX_PATH "${NANOBIND_CMAKE_DIR}")
42
+ find_package(nanobind CONFIG REQUIRED)
43
+
44
+ file(GLOB_RECURSE FOCAL_BINDINGS CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/python/*.cpp")
45
+ nanobind_add_module(_focal STABLE_ABI NB_STATIC ${FOCAL_BINDINGS})
46
+ target_link_libraries(_focal PRIVATE focal_core)
47
+
48
+ install(TARGETS _focal LIBRARY DESTINATION focal)
focalpy-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edison Sun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
focalpy-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: focalpy
3
+ Version: 0.0.1
4
+ Summary: Image processing in C++ with a zero-copy NumPy interface
5
+ Keywords: image-processing,imaging,computer-vision,numpy,cpp
6
+ Author-Email: Edison Sun <edisonsun31@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: C++
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Multimedia :: Graphics
17
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
18
+ Project-URL: Homepage, https://github.com/edisons3608/focal
19
+ Project-URL: Repository, https://github.com/edisons3608/focal
20
+ Project-URL: Issues, https://github.com/edisons3608/focal/issues
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: numpy>=1.23
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == "test"
25
+ Requires-Dist: scipy; extra == "test"
26
+ Requires-Dist: scikit-image; extra == "test"
27
+ Requires-Dist: pillow; extra == "test"
28
+ Description-Content-Type: text/markdown
29
+
30
+ # focal
31
+
32
+ Image processing in C++20 with a zero-copy NumPy interface.
33
+
34
+ ```python
35
+ import focal
36
+
37
+ img = focal.imread("photo.jpg") # linear float32, (h, w, c)
38
+ img = focal.exposure(img, 0.5) # half a stop brighter
39
+ img = focal.gaussian_blur(img, 2.0)
40
+ focal.imwrite("out.png", img) # encodes back to sRGB
41
+ ```
42
+
43
+ ## Design
44
+
45
+ **Everything is a view.** The core type is a non-owning `{pointer, dtype, shape,
46
+ strides}` struct, so a crop, a single channel, and a NumPy array handed over
47
+ from Python are all the same type, and none of them copy. Strides are in bytes,
48
+ matching NumPy exactly, so wrapping an incoming array is copying six integers.
49
+
50
+ **Nothing converts silently.** Array arguments are declared `.noconvert()`, so
51
+ passing a float64 or non-contiguous array raises `TypeError` instead of quietly
52
+ costing a full-size copy. Converting is the caller's decision.
53
+
54
+ **Filtering happens in linear light.** `imread` decodes sRGB to linear float32
55
+ and `imwrite` encodes back. Blurring or resizing gamma-encoded values darkens
56
+ the result — on a synthetic test chart, a σ=3 blur done in gamma space came out
57
+ 29% darker than the same blur done correctly, with individual pixels off by
58
+ 74/255.
59
+
60
+ ## What's here
61
+
62
+ | | |
63
+ |---|---|
64
+ | I/O | `imread`, `imwrite` — PNG, JPEG, BMP, TGA via stb |
65
+ | Point ops | `gamma`, `exposure`, `brightness_contrast`, `invert`, `clip`, `blend`, `apply_lut` |
66
+ | Filtering | `gaussian_blur` (separable) |
67
+ | Sampling | `sample` — nearest and bilinear, border-aware |
68
+ | Color | `srgb_to_linear`, `linear_to_srgb` |
69
+
70
+ Border modes (`clamp`, `reflect`, `reflect101`, `wrap`, `constant`) are shared
71
+ by every spatial op and match `scipy.ndimage`'s semantics.
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install focalpy
77
+ ```
78
+
79
+ Building from source needs a C++20 compiler and CMake:
80
+
81
+ ```bash
82
+ git clone https://github.com/edisons3608/focal
83
+ cd focal
84
+ pip install -e . --no-build-isolation
85
+ pytest
86
+ ```
87
+
88
+ ## Status
89
+
90
+ Early. The API will change. Tests compare against `scipy.ndimage` and `PIL`
91
+ where an oracle exists.
92
+
93
+ ## License
94
+
95
+ MIT. Bundles [stb](https://github.com/nothings/stb) (public domain).
@@ -0,0 +1,66 @@
1
+ # focal
2
+
3
+ Image processing in C++20 with a zero-copy NumPy interface.
4
+
5
+ ```python
6
+ import focal
7
+
8
+ img = focal.imread("photo.jpg") # linear float32, (h, w, c)
9
+ img = focal.exposure(img, 0.5) # half a stop brighter
10
+ img = focal.gaussian_blur(img, 2.0)
11
+ focal.imwrite("out.png", img) # encodes back to sRGB
12
+ ```
13
+
14
+ ## Design
15
+
16
+ **Everything is a view.** The core type is a non-owning `{pointer, dtype, shape,
17
+ strides}` struct, so a crop, a single channel, and a NumPy array handed over
18
+ from Python are all the same type, and none of them copy. Strides are in bytes,
19
+ matching NumPy exactly, so wrapping an incoming array is copying six integers.
20
+
21
+ **Nothing converts silently.** Array arguments are declared `.noconvert()`, so
22
+ passing a float64 or non-contiguous array raises `TypeError` instead of quietly
23
+ costing a full-size copy. Converting is the caller's decision.
24
+
25
+ **Filtering happens in linear light.** `imread` decodes sRGB to linear float32
26
+ and `imwrite` encodes back. Blurring or resizing gamma-encoded values darkens
27
+ the result — on a synthetic test chart, a σ=3 blur done in gamma space came out
28
+ 29% darker than the same blur done correctly, with individual pixels off by
29
+ 74/255.
30
+
31
+ ## What's here
32
+
33
+ | | |
34
+ |---|---|
35
+ | I/O | `imread`, `imwrite` — PNG, JPEG, BMP, TGA via stb |
36
+ | Point ops | `gamma`, `exposure`, `brightness_contrast`, `invert`, `clip`, `blend`, `apply_lut` |
37
+ | Filtering | `gaussian_blur` (separable) |
38
+ | Sampling | `sample` — nearest and bilinear, border-aware |
39
+ | Color | `srgb_to_linear`, `linear_to_srgb` |
40
+
41
+ Border modes (`clamp`, `reflect`, `reflect101`, `wrap`, `constant`) are shared
42
+ by every spatial op and match `scipy.ndimage`'s semantics.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install focalpy
48
+ ```
49
+
50
+ Building from source needs a C++20 compiler and CMake:
51
+
52
+ ```bash
53
+ git clone https://github.com/edisons3608/focal
54
+ cd focal
55
+ pip install -e . --no-build-isolation
56
+ pytest
57
+ ```
58
+
59
+ ## Status
60
+
61
+ Early. The API will change. Tests compare against `scipy.ndimage` and `PIL`
62
+ where an oracle exists.
63
+
64
+ ## License
65
+
66
+ MIT. Bundles [stb](https://github.com/nothings/stb) (public domain).
@@ -0,0 +1,52 @@
1
+ #pragma once
2
+
3
+ #include <cstdint>
4
+
5
+ namespace focal {
6
+
7
+ // What happens when an op reads outside the image. Every spatial op routes
8
+ // its out-of-range reads through map_index() so they all agree about edges.
9
+ //
10
+ // For a 4-wide row `a b c d`, reading left of it gives:
11
+ // Clamp a a a a | a b c d (scipy: "nearest")
12
+ // Reflect d c b a | a b c d (scipy: "reflect") edge sample doubled
13
+ // Reflect101 d c b | a b c d (scipy: "mirror") edge sample not repeated
14
+ // Wrap a b c d | a b c d (scipy: "grid-wrap")
15
+ // Constant -- out of range, caller substitutes a fill value
16
+ enum class Border { Clamp, Reflect, Reflect101, Wrap, Constant };
17
+
18
+ // Sentinel returned for Border::Constant when the index is outside the image.
19
+ inline constexpr int64_t kOutOfBounds = -1;
20
+
21
+ // Fold an arbitrary index into [0, n), or kOutOfBounds under Border::Constant.
22
+ inline int64_t map_index(int64_t i, int64_t n, Border b) {
23
+ if (n <= 0) return kOutOfBounds;
24
+ if (i >= 0 && i < n) return i;
25
+ if (n == 1) return b == Border::Constant ? kOutOfBounds : 0;
26
+
27
+ switch (b) {
28
+ case Border::Clamp:
29
+ return i < 0 ? 0 : n - 1;
30
+
31
+ case Border::Reflect: { // period 2n, edge sample repeated
32
+ const int64_t period = 2 * n;
33
+ int64_t k = ((i % period) + period) % period;
34
+ return k < n ? k : period - 1 - k;
35
+ }
36
+
37
+ case Border::Reflect101: { // period 2n-2, edge sample not repeated
38
+ const int64_t period = 2 * n - 2;
39
+ int64_t k = ((i % period) + period) % period;
40
+ return k < n ? k : period - k;
41
+ }
42
+
43
+ case Border::Wrap:
44
+ return ((i % n) + n) % n;
45
+
46
+ case Border::Constant:
47
+ return kOutOfBounds;
48
+ }
49
+ return kOutOfBounds;
50
+ }
51
+
52
+ } // namespace focal
@@ -0,0 +1,45 @@
1
+ #pragma once
2
+
3
+ #include <cstddef>
4
+ #include <vector>
5
+
6
+ #include "focal/view.hpp"
7
+
8
+ namespace focal {
9
+
10
+ // Owns pixels. Ops never take a Buffer -- they take a View, so the same code
11
+ // works on memory we allocated and on memory NumPy allocated.
12
+ class Buffer {
13
+ public:
14
+ Buffer() = default;
15
+
16
+ Buffer(int64_t height, int64_t width, int64_t channels, Dtype dt)
17
+ : dtype_(dt),
18
+ height_(height),
19
+ width_(width),
20
+ channels_(channels),
21
+ storage_(static_cast<size_t>(height * width * channels *
22
+ dtype_size(dt))) {}
23
+
24
+ View view() {
25
+ return make_view(storage_.data(), dtype_, height_, width_, channels_);
26
+ }
27
+ ConstView view() const {
28
+ return make_view(storage_.data(), dtype_, height_, width_, channels_);
29
+ }
30
+
31
+ Dtype dtype() const { return dtype_; }
32
+ int64_t height() const { return height_; }
33
+ int64_t width() const { return width_; }
34
+ int64_t channels() const { return channels_; }
35
+ size_t size_bytes() const { return storage_.size(); }
36
+
37
+ private:
38
+ Dtype dtype_ = Dtype::F32;
39
+ int64_t height_ = 0;
40
+ int64_t width_ = 0;
41
+ int64_t channels_ = 0;
42
+ std::vector<std::byte> storage_;
43
+ };
44
+
45
+ } // namespace focal
@@ -0,0 +1,44 @@
1
+ #pragma once
2
+
3
+ #include <stdexcept>
4
+ #include <string>
5
+
6
+ #include "focal/view.hpp"
7
+
8
+ namespace focal {
9
+
10
+ // Shared preconditions. Ops call these once at the top, never per pixel.
11
+
12
+ inline void check_f32(ConstView v, const char *who) {
13
+ if (v.dtype != Dtype::F32)
14
+ throw std::invalid_argument(std::string(who) +
15
+ ": expected an F32 view; convert at the edges "
16
+ "of the pipeline, not inside ops");
17
+ }
18
+
19
+ inline void check_same_shape(ConstView a, ConstView b, const char *who) {
20
+ if (a.height != b.height || a.width != b.width || a.channels != b.channels)
21
+ throw std::invalid_argument(std::string(who) + ": shape mismatch (" +
22
+ std::to_string(a.height) + "x" +
23
+ std::to_string(a.width) + "x" +
24
+ std::to_string(a.channels) + " vs " +
25
+ std::to_string(b.height) + "x" +
26
+ std::to_string(b.width) + "x" +
27
+ std::to_string(b.channels) + ")");
28
+ }
29
+
30
+ inline void check_channels(ConstView v, int64_t want, const char *who) {
31
+ if (v.channels != want)
32
+ throw std::invalid_argument(std::string(who) + ": expected " +
33
+ std::to_string(want) + " channels, got " +
34
+ std::to_string(v.channels));
35
+ }
36
+
37
+ // The last channel of a 2- or 4-channel image is alpha: a linear coverage
38
+ // fraction, not a colour. Tone curves and transfer functions must skip it.
39
+ // io.cpp applies the same rule; keep them agreeing by using this.
40
+ inline bool is_alpha_channel(int64_t channels, int64_t c) {
41
+ return (channels == 2 || channels == 4) && c == channels - 1;
42
+ }
43
+
44
+ } // namespace focal
@@ -0,0 +1,54 @@
1
+ #pragma once
2
+
3
+ #include <algorithm>
4
+ #include <cstdint>
5
+
6
+ #include "focal/buffer.hpp"
7
+ #include "focal/view.hpp"
8
+
9
+ namespace focal {
10
+
11
+ // Normalization convention, decided once here so no op re-decides it:
12
+ // u8 255 <-> 1.0f
13
+ // u16 65535 <-> 1.0f
14
+ // f32 passthrough, unclamped
15
+ // Values are clamped to [0, 1] on the way down into an integer type.
16
+
17
+ inline float unit_from_u8(uint8_t v) { return v * (1.0f / 255.0f); }
18
+ inline float unit_from_u16(uint16_t v) { return v * (1.0f / 65535.0f); }
19
+
20
+ inline uint8_t u8_from_unit(float f) {
21
+ return static_cast<uint8_t>(std::clamp(f, 0.0f, 1.0f) * 255.0f + 0.5f);
22
+ }
23
+ inline uint16_t u16_from_unit(float f) {
24
+ return static_cast<uint16_t>(std::clamp(f, 0.0f, 1.0f) * 65535.0f + 0.5f);
25
+ }
26
+
27
+ // Read one sample as a unit float, whatever the view's dtype. Used at the
28
+ // edges of the library; ops themselves run on f32 views and skip this.
29
+ inline float load_unit(ConstView v, int64_t y, int64_t x, int64_t c = 0) {
30
+ switch (v.dtype) {
31
+ case Dtype::U8: return unit_from_u8(v.at<uint8_t>(y, x, c));
32
+ case Dtype::U16: return unit_from_u16(v.at<uint16_t>(y, x, c));
33
+ case Dtype::F32: return v.at<float>(y, x, c);
34
+ }
35
+ return 0.0f;
36
+ }
37
+
38
+ inline void store_unit(View v, int64_t y, int64_t x, int64_t c, float f) {
39
+ switch (v.dtype) {
40
+ case Dtype::U8: v.at<uint8_t>(y, x, c) = u8_from_unit(f); break;
41
+ case Dtype::U16: v.at<uint16_t>(y, x, c) = u16_from_unit(f); break;
42
+ case Dtype::F32: v.at<float>(y, x, c) = f; break;
43
+ }
44
+ }
45
+
46
+ // Whole-image dtype conversion.
47
+ Buffer convert(ConstView src, Dtype dst);
48
+
49
+ // Decode integer sRGB to linear f32, and encode back. These are the two calls
50
+ // that belong at the very edges of a pipeline.
51
+ Buffer srgb_to_linear_f32(ConstView src);
52
+ Buffer linear_f32_to_srgb(ConstView src, Dtype dst);
53
+
54
+ } // namespace focal
@@ -0,0 +1,31 @@
1
+ #pragma once
2
+
3
+ #include <string>
4
+
5
+ #include "focal/buffer.hpp"
6
+ #include "focal/view.hpp"
7
+
8
+ namespace focal {
9
+
10
+ // Image file I/O, backed by stb_image / stb_image_write (vendored in
11
+ // third_party/). Supports 8-bit PNG, JPEG, BMP, TGA and reads GIF/PSD/PIC.
12
+ // Not for 16-bit PNG, EXR, TIFF or camera raw -- swap in libpng /
13
+ // libjpeg-turbo / tinyexr / libraw when those matter.
14
+ //
15
+ // Files hold sRGB-encoded bytes. load() decodes them to LINEAR f32 so that
16
+ // everything downstream -- blurring, resizing, compositing -- is correct;
17
+ // save() encodes back. Use load_raw()/save_raw() only when you deliberately
18
+ // want the stored values, e.g. inspecting a file's actual bytes.
19
+
20
+ // Decode to linear f32, channels as stored in the file (1, 2, 3 or 4).
21
+ // Pass want_channels to force a channel count (1 = grey, 3 = RGB, 4 = RGBA).
22
+ Buffer load(const std::string &path, int want_channels = 0);
23
+
24
+ // Decode to u8 exactly as stored, with no transfer-function conversion.
25
+ Buffer load_raw(const std::string &path, int want_channels = 0);
26
+
27
+ // Encode linear f32 (or pass u8 through) and write. Format comes from the
28
+ // file extension: .png .jpg .jpeg .bmp .tga
29
+ void save(const std::string &path, ConstView img, int jpeg_quality = 95);
30
+
31
+ } // namespace focal
@@ -0,0 +1,20 @@
1
+ #pragma once
2
+
3
+ #include <vector>
4
+
5
+ #include "focal/border.hpp"
6
+ #include "focal/view.hpp"
7
+
8
+ namespace focal {
9
+
10
+ // Normalized 1D Gaussian weights, radius = int(truncate * sigma + 0.5).
11
+ // Matches scipy.ndimage's kernel exactly, which is what the tests compare to.
12
+ std::vector<float> gaussian_kernel1d(float sigma, float truncate = 4.0f);
13
+
14
+ // Separable Gaussian blur. src and dst must be F32, same shape, and must not
15
+ // alias. Separable means two 1D passes (O(2r) per pixel) instead of one 2D
16
+ // convolution (O(r^2)) -- the whole reason to special-case Gaussians.
17
+ void gaussian_blur(ConstView src, View dst, float sigma,
18
+ Border border = Border::Reflect101, float cval = 0.0f);
19
+
20
+ } // namespace focal
@@ -0,0 +1,110 @@
1
+ #pragma once
2
+
3
+ #include <cstdint>
4
+
5
+ #include "focal/check.hpp"
6
+ #include "focal/view.hpp"
7
+
8
+ namespace focal {
9
+
10
+ // Point operations: every output sample depends only on the sample at the same
11
+ // position. No neighbourhood, no border handling, and safe to run in place
12
+ // (pass the same memory as src and dst).
13
+ //
14
+ // WHICH COLOUR SPACE
15
+ //
16
+ // Filtering must happen in linear light because blurring MIXES pixels, and
17
+ // mixing light is physics. Tone adjustment is different -- it remaps a single
18
+ // value, and the result people expect comes from remapping DISPLAY-REFERRED
19
+ // (sRGB-encoded) values. So:
20
+ //
21
+ // exposure() linear. It is a light-amount change: multiply.
22
+ // gamma() display. Definitionally an encoding change.
23
+ // brightness_contrast() display. Matches what an editor's sliders do.
24
+ // apply_lut() display, normally -- it is a curve on tone.
25
+ // invert(), blend() either; blend of colours wants linear.
26
+ //
27
+ // These functions do not convert for you. Hand them a view already in the
28
+ // space you mean.
29
+
30
+ // The workhorse. Everything else in this header is three lines on top of it.
31
+ // fn is called per sample; alpha is passed through untouched when skip_alpha.
32
+ template <typename F>
33
+ void map(ConstView src, View dst, F fn, bool skip_alpha = true) {
34
+ check_f32(src, "map");
35
+ check_f32(dst, "map");
36
+ check_same_shape(src, dst, "map");
37
+
38
+ for (int64_t y = 0; y < src.height; ++y) {
39
+ for (int64_t x = 0; x < src.width; ++x) {
40
+ for (int64_t c = 0; c < src.channels; ++c) {
41
+ const float v = src.at<float>(y, x, c);
42
+ dst.at<float>(y, x, c) =
43
+ (skip_alpha && is_alpha_channel(src.channels, c)) ? v : fn(v);
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+
50
+ template <typename F>
51
+ void map2(ConstView src1, ConstView src2, View dst, F fn, bool skip_alpha = true) {
52
+ check_f32(src1, "map2");
53
+ check_f32(src2, "map2");
54
+ check_f32(dst, "map2");
55
+ check_same_shape(src1, src2, "map2");
56
+ check_same_shape(src1, dst, "map2");
57
+
58
+ for (int64_t y = 0; y < src1.height; ++y) {
59
+ for (int64_t x = 0; x < src1.width; ++x) {
60
+ for (int64_t c = 0; c < src1.channels; ++c) {
61
+ const float v = src1.at<float>(y, x, c);
62
+ const float w = src2.at<float>(y, x, c);
63
+ dst.at<float>(y, x, c) =
64
+ (skip_alpha && is_alpha_channel(src1.channels, c)) ? v : fn(v, w);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ // v -> v^g. Values below zero are left alone (pow of a negative is NaN).
71
+ void gamma(ConstView src, View dst, float g);
72
+
73
+ // v -> v * 2^stops. Linear-light only; on encoded values it is meaningless.
74
+ void exposure(ConstView src, View dst, float stops);
75
+
76
+ // v -> (v - pivot) * contrast + pivot + brightness
77
+ //
78
+ // The pivot is why contrast does not also brighten: it is the value held
79
+ // fixed while everything else spreads away from it. 0.5 for display-referred
80
+ // values, 0.18 (middle grey) if you insist on working linear.
81
+ void brightness_contrast(ConstView src, View dst, float brightness,
82
+ float contrast, float pivot = 0.5f);
83
+
84
+ // v -> 1 - v.
85
+ void invert(ConstView src, View dst);
86
+
87
+ // v -> clip(v, lo, hi).
88
+ void clip(ConstView src, View dst, float lo = 0.0f, float hi = 1.0f);
89
+
90
+ // dst = a + (b - a) * t, elementwise. a and b must have the same shape.
91
+ void blend(ConstView a, ConstView b, View dst, float t);
92
+
93
+ // Apply a tone curve held as a lookup table.
94
+ //
95
+ // DOMAIN: the n entries span [0, 1] inclusive, so lut[0] is the output for
96
+ // input 0.0 and lut[n-1] the output for 1.0. Inputs between entries are
97
+ // linearly interpolated; inputs outside [0, 1] are clamped to the ends.
98
+ //
99
+ // This is also the fast path for any expensive curve -- bake gamma or an sRGB
100
+ // transfer into 1024 entries once instead of calling pow() per pixel.
101
+ void apply_lut(ConstView src, View dst, const float *lut, int n);
102
+
103
+ // Fill `lut` (n entries, same domain as above) by sampling fn over [0, 1].
104
+ template <typename F>
105
+ void bake_lut(float *lut, int n, F fn) {
106
+ for (int i = 0; i < n; ++i)
107
+ lut[i] = fn(static_cast<float>(i) / static_cast<float>(n - 1));
108
+ }
109
+
110
+ } // namespace focal