diff --git a/LICENCE b/LICENSE similarity index 99% rename from LICENCE rename to LICENSE index 261eeb9..4009bc5 100644 --- a/LICENCE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright [yyyy] Les Wright Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -198,4 +198,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..b98429e --- /dev/null +++ b/NOTICE @@ -0,0 +1,23 @@ +PySpectrometer2 Refactor + +Copyright 2025 Gomills + +This product mostly includes software developed by Les Wright (https://github.com/leswright1977/PySpectrometer2). + +Original PySpectrometer2 Copyright Notice: +Copyright [Original Year] Les Wright + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +This is a derivative work based on PySpectrometer2, with modifications +to modernize the codebase, improve structure, and add new features. diff --git a/README.md b/README.md index bb624da..0605a37 100644 --- a/README.md +++ b/README.md @@ -1,318 +1,168 @@ -# PySpectrometer2 +# PySpectrometer2 Refactor -The second incarnation of the Spectrometer project! +A Python-based spectrometer application designed for use with Raspberry Pi. This application captures images from the Raspberry Pi camera, processes them into spectral data, and provides a GUI for visualization and calibration. -This is a more advanced, but more flexible version of the original program. It changes the spectrometer from educational 'toy' to serious instrument, which can easily compete with commercial units costing thousands of dollars! +I (Gomills) made this codebase as a refactor of the amazing repo https://github.com/leswright1977/PySpectrometer2, by Les Wright, the original author. +My aim was to convert a script-type software to a more defined and modern codebase by: -This program, hardware design, and associated information is Open Source (see Licence), but if you have gotten value from these kinds of projects and think they are worth something, please consider donating: https://paypal.me/leslaboratory?locale.x=en_GB -This project is a follow on from: https://github.com/leswright1977/PySpectrometer +- Modernizing to 2025's Python Standards +- Modularizing +- Documenting +- Improving readability and clarity +- Memory safety +- Not so much focus (though big things happened): optimization -This readme is accompanied by youtube videos, showing how to build and use the spectrometer! -Visit my Youtube Channel at: https://www.youtube.com/leslaboratory +This resulted, or at least I hope, into an environment that invites developers and any user to contribute/modify the software for everyone's purpose and to maintain the repo by modernizing it to today's software standards. -There is a video on this project here: https://youtu.be/SCp9T8NKfnM +## License -## Rationale for the new build +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. -Recent changes in the OS (Bullseye) broke the old version e.g broken video, broken dependencies and so on. PySpectrometer v3.0 was hacked and fixed as of 3.1), however I have been thinking about a rewrite of this software for a while, so here it is! +This is a derivative work based on the original PySpectrometer2 by Les Wright. -Accuracy has been significantly improved by implementing multi-wavelength calibration and a Polynomial regression data fit to compute wavelengths as precisely as possible across the measured range. +## User Guide -Tk has been dropped as the GUI to allow easier maintainability, extendability and flexibility. The new interface is coded entirely in OpenCV, and whilst things like trackbars and buttons are now dropped in favour of keybindings, this frees up screen real estate, and allows the support of resizing without fuss, and fullscreen mode is now supported! +### Installation -In Fullscreen mode on Raspberry Pi systems, the interface is designed to fit 800 x 480 screens, which seem to be a common resolution for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. +0. **Download this repo**: as a zip and extract it in your Raspberry Pi. Place it in a comfortable, or any that you choose, folder. -![Screenshot](media/calibrated.png) +Follow these steps to install the required dependencies on your Raspberry Pi. If you don't understand or find it difficult, copy and paste this README.md and ask ChatGPT. +1. **Check and Install picamera2:** -### Whats new: + ```bash + if python3 -c "import picamera2" &> /dev/null; then + version=$(python3 -c "import picamera2; print(getattr(picamera2, '__version__', 'unknown'))") + path=$(python3 -c "import picamera2; print(picamera2.__file__)") + echo "picamera2 is installed" + echo "Version: $version" + else + echo "picamera2 is not installed. Proceeding to install..." + sudo apt install python3-picamera2 + fi + ``` -- Higher resolution (800px wide graph). +2. **Check and Install numpy:** -- 3 row pixel averaging of sensor data. + ```bash + if python3 -c "import numpy" &> /dev/null; then + version=$(python3 -c "import numpy; print(numpy.__version__)") + path=$(python3 -c "import numpy; print(numpy.__file__)") + echo "numpy is installed" + echo "Version: $version" + else + echo "numpy is not installed. Proceeding to install..." + sudo python3 -m pip install "numpy>=2.2.6" + fi + ``` -- Fullscreen option for the Spectrometer graph. +3. **Install pydantic and opencv-python:** -- 3rd order polynomial fit of calibration data for accurate measurement. + ```bash + sudo python3 -m pip install "pydantic>=2.11.7"; sudo python3 -m pip install "opencv-python>=4.12.0.88" + ``` -- Improved graph labelling. + To verify opencv installation: -- Labelled measurement cursors. + ```bash + if python3 -c "import cv2" &> /dev/null; then + version=$(python3 -c "import cv2; print(cv2.__version__)") + path=$(python3 -c "import cv2; print(cv2.__file__)") + echo "opencv-python is installed" + echo "Version: $version" + echo "Path: $path" + else + echo "opencv-python is not installed" + fi + ``` -- Optional waterfall display for recording spectra changes over time. +### Usage -- Key Bindings for all operations. +Once all dependencies are installed and your Raspberry Pi camera is properly set up (refer to Les Wright's videos for hardware setup guidance), you can run the spectrometer application. -- Analogue Gain control for the Picam +#### Running the Application +Navigate to the project directory (use the 'cd' command and the help of ChatGPT) and execute: -The functionality of the previous version has been retained, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. +```bash +python3 main.py +``` -A very cool addition to this project is a Waterfall display! This allows the recording of change in in wavelength over time. -For my purposes this is especially useful for Dye Lasers, however this may be of use to those observing the spectra of light sources that may change in intensity or wavelength. +This will start the spectrometer in its default mode. The application will: -Example waterfall display of a fluorescent lamp: -![Screenshot](media/waterfall-20221013--210412.png) +1. Load configuration from `src/config/config.json` +2. Initialize the Raspberry Pi camera using picamera2 +3. Launch the OpenCV-based GUI for real-time display +4. Begin capturing and processing spectral data +#### User Interface -Waterfall display of a Dye laser being tuned (Coumarin-1). The jagged tuning curve is because this laser was hand-tuned! -![Screenshot](media/waterfall-20221013--205708.png) +- **Display**: Real-time camera feed with overlaid spectral information +- **Mouse Interaction**: Click on spectral lines for calibration and measurement +- **Key Bindings**: Use keyboard shortcuts to switch modes and perform actions (refer to in-app help or source code for details). Refer to Les Wright videos where he explains how to set it up -Below the tuning curve of Rhodamine 6G -![Screenshot](media/waterfall-20221013--205840.png) +#### Configuration +Customize behavior by editing `src/config/config.json`: +- Camera settings (resolution, frame rate) +- Processing parameters (filter settings, peak detection) +- GUI options (full screen, display modes) +#### Tips -# Hardware - -The hardware is simple and widely available and so should be easily to duplicate without critical alignment or difficult construction. The hard work was developing the software. - -Resolution/accuracy seems to be down to the nanometre with a well built and calibrated setup, which is excellent for the price of the hardware, especially when you consider the price of commercial components such as the Hamamatsu C12880MA breakout boards which run north of 300 bucks, and has a resolution of 15nm. Of course, this build is physically much larger, but not enormous! - - - -## Standard Spectroscope - -For the standard build, I used a pocket spectroscope(link below) coupled into a picamera by means of a zoom lens. -The job is simple: Mount the zoom lens on the picam, and mount the spectroscope in front, and focus the camera on the spectrum, until it is sharp and clear. Use either daylight (which has pronounced Fraunhoffer lines) or a fluorescent lamp, which has pronounced emission lines. The following command will help you: **libcamera-hello -t 0** - -![Screenshot](media/scope.png) - -The hardware consists of: - -- A commercial Diffraction grating Spectroscope https://www.patonhawksley.com/product-page/benchtop-spectroscope - -- A Raspberry Pi Camera (with an M12 Thread) https://thepihut.com/products/raspberry-pi-camera-adjustable-focus-5mp - -- A CCTV Lens with Zoom (M12 Thread) (Search eBay for F1.6 zoom lens) - -Everything is assembled on an aluminium base (note the Camera is not cooled, the heatsink was a conveniently sized piece of aluminium.) - -![Screenshot](media/parts.png) - -![Screenshot](media/pi.png) - -## Miniture Spectroscope - -The build is as simple as the standard version, however, uses a miniature pocket spectrometer. - -![Screenshot](media/minispect.png) - -- A commercial Diffraction grating Pocket Spectroscope: https://www.patonhawksley.com/product-page/pocket-spectroscope - -- A Raspberry Pi Camera (with an M12 Thread): https://thepihut.com/products/raspberry-pi-camera-adjustable-focus-5mp - -- M12x0.5 F2.0 Fixed 12mm Focal length Lens: (search eBay) - - - - -## Stand alone unit - -![Screenshot](media/mini.png) - -Above, a compact unit built with a Hyperpixel 4 inch screen, running on fullscreen mode (800 x 480) - -https://shop.pimoroni.com/products/hyperpixel-4?variant=12569539706963 - -## Custom units - -There is nothing to stop you building a spectrometer head with a couple of razor blades, a diffraction grating, a couple of lenses and a Picam! The software should work just the same as shown in this readme! - - - - -# User Guide - -## Key Bindings: - -### Graph Display Controls -* t/g = Analogue Gain up/down (not available on USB version, see below for alternative camera controls) -* o/l = savpoly up/down -* i/k = peak width up/down -* u/j = Label threshold up/down -* h = hold peaks - -### Calibration and General Software -* m = measure (Toggles measure function. In this mode a crosshairs is displayed on the Spectrogram that allows the measurement of wavelength) -* p = record pixels (Toggles pixel function (Part of the calibration procedure) allows the selection of multiple pixel positions on the graph) -* x = clear points (Clear selected pixel points above) -* c = calibrate (Enter the calibration routine, requires console input) -* s = save data (Saves Spectrograph as png and CSV data. Saves waterfall as png. -* q = quit (Quit Program) - -## Starting the program - -First, clone this repo! - -In /src you will find: - -* PySpectrometer2-Picam2-v1.0.py (PySpectrometer for Raspberry Pi) -* PySpectrometer2-USB-v1.0.py (USB version of this program (This is for USB Cameras See end of Readme)). -* specFunctions.py (A library of functions including: Wavelength to RGB, SavGol filter from Scipy, Peak detect from peakutils, readcal and writecal. - -## Dependencies - -Run: **sudo apt-get install python3-opencv** - -**Also note, this build is designed for Raspberry Pi OS Bullseye, and will only work with the new libcamera based python library (picamera2)** -It will **not** work with older versions of Raspbery Pi OS. You **will** however be able to use PySpectrometer2-USB-v1.0.py with an external USB camera with other Operating Systems. - - -To run the program, first make it executable by running: **chmod +x PySpectrometer2-Picam2-v1.0.py** - -Run by typing: **./PySpectrometer2-Picam2-v1.0.py** - -Note to also display the waterfall display, run with the option: **./PySpectrometer2-Picam2-v1.0.py --waterfall** - -To run in fullscreen mode (perform calibration in standard mode first), run with the option: **./PySpectrometer2-Picam2-v1.0.py --fullscreen** - -When first started, the spectrometer is in an uncalibrated state! You must therefore perform the calibration procedure, but at this stage you should be able to focus and align your camera with your spectroscope using the preview window. Is is expected that red light is on the right, and blue-violet on the left. -An excellent choice for this is daylight, as well defined Fraunhoffer lines are indicative of good camera focus. - -## Calibration - -This version of the PySpectrometer performs Polynomial curve fitting of the user provided calibration wavelengths. This procedure if done with care with result in a precision instrument! - -When light from a diffraction grating falls upon a flat sensor the dispersion of light is not linear, and so calibration with just two data points (as in the old version of this software) will result in inaccurate readings. This nonlinearity is likely compounded by additional nonlinearities introduced by the camera lenses. To address the nonlinearity, the user must provide the pixel positions of at least 3 known wavelengths (4 to 6 is highly recommended for high accuracy!). This information is then used by the program to compute the wavelengths of every single pixel position of the sensor. - -Where 3 wavelengths are used for calibration, the software will perform a 2nd order polynomial fit (Reasonably accurate) - -Where 4 or more wavelengths are used, the software will perform a 3rd order polynomial fit (Very accurate) - -Assuming your physical spectrometer setup is rigid and robust (not held together with gravity, tape or hot glue!), calibration will only need to be done once (Data is saved to a file called: caldata.txt), and therafter when any change is made to the physical setup. - -Direct your Spectrometer at a light source with many discrete emission lines. A target illuminated by Lasers would be an excellent (though very expensive!) choice! An inexpensive alternative is a Fluorescent tube. - -You should be able to identify several peaks in your graph, now you need to match them up with known good data. For serious work I would recommend an academic resource such as: https://physics.nist.gov/PhysRefData/Handbook/Tables/mercurytable2.htm however in the spirit of citizen science (and because fluorescent lamps are somewhat variable in manufacture), I would recommend this wikipedia article to get you started: https://en.wikipedia.org/wiki/Fluorescent_lamp have a read, and scroll down to the section called: Phosphor composition. In here you will find emission spectra of a variety of fluorescent lamps! - -Likely the most useful is this graph: https://commons.wikimedia.org/wiki/File:Fluorescent_lighting_spectrum_peaks_labeled_with_colored_peaks_added.png - -These are the notable visible peaks: -* 1 405.4 nm (Mercury) -* 2 436.6 nm (Mercury) -* 3 487.7 (Terbium) -* 4 542.4 (Terbium) -* 5 546.5 (Mercury) -* 12 611.6 (Europium) -* 14 631.1 (Europium) - -Once you have identified some peaks, at least 3, but even better 4 to 6, first press 'h' to toggle on peak hold, this will stabilize the graph, and even allow you to switch off the light source! - -Press the 'p' key on the keyboard. This will toggle on the pixel measuring crosshairs, move the crosshairs to each of your peaks, and click once the crosshairs are aligned with the 'flagpole' of the wavelength marker. -Rinse and repeat for your identified peaks. (Note it makes sense to do this from left to right!) - -![Screenshot](media/pointsadded.png) - -Once you have selected all of your peaks, press 'c' and turn your attention to the terminal window. -For each pixel number, enter the identified wavelength. - -![Screenshot](media/console.png) - -Once you have entered the wavelengths for each data point, the software will recalibrate the graticule and its internal representation of all the wavelength data. -In the console, it will print out the value of R-Squared. This value will give an indication of how well the calculated data matches your input data. The closer this value is to 1, the more accurately you recorded your wavelengths! for example a six nines fit (0.999999xxxx) is excellent, and 5 nines is good. If it is a way off, one or more of your identified wavelengths may be incorrect, and you should repeat the calibaration procedure! (Press 'x' to clear the points, and repeat the calibration procedure) - -### Check your work -Refer back to the graph from the wiki, can you identify with a reasonable degree of accuracy other peaks? (bearing in mind your fluorescent lamp may differ from the one on the wiki!). - -![Screenshot](media/calibrated.png) - -In the screenshot above above, a well defined peak (not used as a caibration value) at 587.4nm has been detected. Referring to the Wiki this is listed at 587.6nm, only 0.2nm off with a five nines calibration! :-) -For unlabelled peaks, pressing 'm' will toggle on the measurement crosshairs, that display wavelength for any given position. - -Calibration data is written to a file (caldata.txt), so calibration is retained when the program is restarted. - -# Saving Data - -Pressing 's' will save all data. It saves graph and waterfall data as PNG images, with date and time as part of the filename. -Additionally it saves graph data as CSV that can then be opened in other programs such as OpenOffice on the Pi. - -![Screenshot](media/csv.png) - -# Arbitrary measurement - -Pressing the 'm' key will toggle a measurement cursor. This can be used (once the intrument has been calibrated) to arbitratily measure any point on the graph. The following screenshot shows the measurement of a possible Terbium or Mercury line at 577nm - -![Screenshot](media/terbium-measure.png) - -Below: Another example of measurement cursors on a weak line at 437nm. This is from a high pressure Sodium lamp as is in fact likely Mercury (HP Sodium lamps contain Xenon, Mecury and Sodium). - -The prominent peak at 589nm is Sodium. -Of the other peaks: 546.6nm is Mercury, 568.9nm is Sodium, 577nm and 579nm (unlabelled, but just to the left of the 589nm Sodium peak) are both Mercury. - -![Screenshot](media/high-pressure-sodium.png) - -# USB Camera Version - -A version of the software is provided for those who wish to use third party USB cameras with the Pi, or even a USB camera with any other Linux box! - -The following command line options must be considered: - -- Video device number -- Framerate - -**Note: the expected resolution from USB cameras is 800x600, other resolutions will cause the software to crash!** - -For an external USB camera, first find the device by issuing: -**v4l2-ctl --list-devices** - -Once you have determined this, you can run the program. For example if your camera is /dev/video3 and you require a framerate of 15fps you would issue: - -**./PySpectrometer2-USB-v1.0.py --device 3 --fps 15** - -If you want fine control over camera settings use guvcview: **sudo apt-get install guvcview** - -You can run guvcview at the same time as the spectrometer software, so long as you disable guvcview preview, like this: - -(assuming your device is /dev/video3) - -**guvcview --device /dev/video3 --control_panel** - -This will allow you to control: -- Brightness -- Contrast -- Saturation -- Gain -- Other settings (depending on camera, see note below) - -Note: Guvcview is a more sensible choice for camera control, than trying to shoehorn in USB camera functionality into this code. The Python OpenCV libary has limited and oftentimes broken support for the huge variety of USB cameras out there, and so direct control with a tried and tested utility makes sense! - - - - -# Future work: - -It is planned to add inputs of some description, to allow the use of buttons and knobs to control the Spectrometer. - -The type of inputs will depend on oddly the type of screen! - -The hyperpixel displays consume all of the GPIO on the Pi, however buttons could easily be provided if they talk HID. - -DSI displays could be used, however seemingly that might require the user roll back to legacy camera support! - -HDMI displays can be used, and this would free up all the GPIO. - -A one size fits all approach would be a HID device, there is plenty of choice, including using a Teensy or an Arduino and buttons, or even a number pad with custom keycaps. - - - -I am thinking of implementing something approaching autocalibration, though this might be difficult implement for all use-cases. - - - +- For detailed setup instructions, watch Les Wright's original PySpectrometer2 videos +## Developer Guide +### Software Architecture and Algorithm +- **Image Capture and Processing**: Captures frames from Raspberry Pi camera, crops to central band, converts to grayscale, and averages intensities for noise reduction. +- **Pixel-to-Wavelength Conversion**: Uses polynomial fitting (2nd or 3rd order) based on calibration points to map pixel positions to wavelengths. +- **Modes**: + - Emittance: Handles emittance spectroscopy capture and processing. + - Transmittance: Manages transmittance spectroscopy capture (TODO). + - Waterfall: Implements waterfall display for spectral data visualization (TODO). +- **GUI**: OpenCV-based interface for display and user interaction. +- **State Management**: Each user session is described by a state manager, an encapsulated class object that contains all runtime variables and methods to modify them ("Single Source of Truth") +- **Configuration**: JSON-based configuration for constants, runtime settings, and defaults. +### Dependencies +This project requires several Python packages and OS-level dependencies, particularly for Raspberry Pi camera support. Note that due to OS-level interactions, virtual environments cannot be used. Picamera2 for Python is preinstalled on Raspberry Pi OS Bullseye (or later) images, as well as Numpy. However installation for those is provided in any case in this README.md. +#### Python Packages +- **opencv-python**: Handles image capture, processing, display, and GUI operations. +- **numpy**: Provides numerical computations, array manipulation, and polynomial fitting for wavelength calibration. +- **picamera2**: Interfaces with the Raspberry Pi camera, built on top of libcamera2 for hardware access. Their developers insist in using apt to ensure installing alongside a compatible libcamera library, which is OS-level +- **pydantic**: Validates and manages configuration data from JSON files. +``` +While picamera2 can be installed using pip (`pip install picamera2`), this may result in version mismatches between picamera2 and libcamera, potentially causing issues. Their developers recommend using apt. +``` +#### Important Notes +- The interaction between picamera2 and the OS-level libcamera2 library makes it impossible to use virtual environments. All dependencies must be installed system-level. +- This limitation prevents the use of comfortable Python package managers like uv +- But this shouldn't bee too much of a hassle since it's run on a Raspberry pi and not a personal computer used for multipurposes +### TODOs +- Update GUI to include more info +- Refactor key bindings +- Add option for higher-order polynomial fitting in calibration. +- Migrate calibration data from .txt to .csv with descriptive headers. +- Add more descriptive logging +- Introduce more testing. +- Implement transmittance and waterfall mode (this should happen after all other TODOs are accomplished. Because if done otherwise, it will be a real mess) +### Licensing +This project is a derivative work of PySpectrometer2 by Les Wright, licensed under Apache License 2.0. When contributing: +- Ensure all new files include the Apache License header +- Preserve original copyright notices +- Document any significant modifications +- Follow Apache License 2.0 terms for redistribution \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..006c4e8 --- /dev/null +++ b/main.py @@ -0,0 +1,19 @@ +# Copyright 2025 Les Wright +# Contributor 2025: Gomills +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.pyspectrometer_picam2 import start_spectrometer + +if __name__ == "__main__": + start_spectrometer(config_path="src/config/config.json") \ No newline at end of file diff --git a/media/Screenshot_2022-10-16_14-41-14.png b/media/Screenshot_2022-10-16_14-41-14.png deleted file mode 100644 index 47d0455..0000000 Binary files a/media/Screenshot_2022-10-16_14-41-14.png and /dev/null differ diff --git a/media/Screenshot_2022-10-16_14-43-11.png b/media/Screenshot_2022-10-16_14-43-11.png deleted file mode 100644 index 510f34a..0000000 Binary files a/media/Screenshot_2022-10-16_14-43-11.png and /dev/null differ diff --git a/media/calibrated.png b/media/calibrated.png deleted file mode 100644 index 03bf8fe..0000000 Binary files a/media/calibrated.png and /dev/null differ diff --git a/media/console.png b/media/console.png deleted file mode 100644 index dd1d0d4..0000000 Binary files a/media/console.png and /dev/null differ diff --git a/media/csv.png b/media/csv.png deleted file mode 100644 index a128348..0000000 Binary files a/media/csv.png and /dev/null differ diff --git a/media/high-pressure-sodium.png b/media/high-pressure-sodium.png deleted file mode 100644 index bc6693d..0000000 Binary files a/media/high-pressure-sodium.png and /dev/null differ diff --git a/media/media.lst b/media/media.lst deleted file mode 100644 index 8b13789..0000000 --- a/media/media.lst +++ /dev/null @@ -1 +0,0 @@ - diff --git a/media/mini.png b/media/mini.png deleted file mode 100644 index 6a178e5..0000000 Binary files a/media/mini.png and /dev/null differ diff --git a/media/minispect.png b/media/minispect.png deleted file mode 100644 index 3878a08..0000000 Binary files a/media/minispect.png and /dev/null differ diff --git a/media/parts.png b/media/parts.png deleted file mode 100644 index 3fd8d8b..0000000 Binary files a/media/parts.png and /dev/null differ diff --git a/media/pi.png b/media/pi.png deleted file mode 100644 index 7202d70..0000000 Binary files a/media/pi.png and /dev/null differ diff --git a/media/pointsadded.png b/media/pointsadded.png deleted file mode 100644 index 8081af3..0000000 Binary files a/media/pointsadded.png and /dev/null differ diff --git a/media/scope.png b/media/scope.png deleted file mode 100644 index ff51938..0000000 Binary files a/media/scope.png and /dev/null differ diff --git a/media/spectrum-20221013--210412.png b/media/spectrum-20221013--210412.png deleted file mode 100644 index 40d23e0..0000000 Binary files a/media/spectrum-20221013--210412.png and /dev/null differ diff --git a/media/spectrum-20221016--144134.png b/media/spectrum-20221016--144134.png deleted file mode 100644 index 9735f41..0000000 Binary files a/media/spectrum-20221016--144134.png and /dev/null differ diff --git a/media/spectrum-20221016--144215.png b/media/spectrum-20221016--144215.png deleted file mode 100644 index 8081af3..0000000 Binary files a/media/spectrum-20221016--144215.png and /dev/null differ diff --git a/media/spectrum-20221016--144325.png b/media/spectrum-20221016--144325.png deleted file mode 100644 index caf0675..0000000 Binary files a/media/spectrum-20221016--144325.png and /dev/null differ diff --git a/media/spectrum-20221016--144355.png b/media/spectrum-20221016--144355.png deleted file mode 100644 index 03bf8fe..0000000 Binary files a/media/spectrum-20221016--144355.png and /dev/null differ diff --git a/media/terbium-measure.png b/media/terbium-measure.png deleted file mode 100644 index 1cf5fef..0000000 Binary files a/media/terbium-measure.png and /dev/null differ diff --git a/media/uncal.png b/media/uncal.png deleted file mode 100644 index 9735f41..0000000 Binary files a/media/uncal.png and /dev/null differ diff --git a/media/waterfall-20221013--205708.png b/media/waterfall-20221013--205708.png deleted file mode 100644 index c45736d..0000000 Binary files a/media/waterfall-20221013--205708.png and /dev/null differ diff --git a/media/waterfall-20221013--205840.png b/media/waterfall-20221013--205840.png deleted file mode 100644 index 2263efb..0000000 Binary files a/media/waterfall-20221013--205840.png and /dev/null differ diff --git a/media/waterfall-20221013--210412.png b/media/waterfall-20221013--210412.png deleted file mode 100644 index 1201366..0000000 Binary files a/media/waterfall-20221013--210412.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa5c2bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "PySpectrometer2.1" +version = "0.1.0" +description = "PySpectrometer2.1" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "numpy>=2.2.6", + "opencv-python>=4.12.0.88", + "picamera2>=0.3.30", + "pydantic>=2.11.7", +] diff --git a/src/PySpectrometer2-Picam2-v1.0.py b/src/PySpectrometer2-Picam2-v1.0.py deleted file mode 100644 index c0965ef..0000000 --- a/src/PySpectrometer2-Picam2-v1.0.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env python3 - -''' -PySpectrometer2 Les Wright 2022 -https://www.youtube.com/leslaboratory -https://github.com/leswright1977 - -This project is a follow on from: https://github.com/leswright1977/PySpectrometer - -This is a more advanced, but more flexible version of the original program. Tk Has been dropped as the GUI to allow fullscreen mode on Raspberry Pi systems and the iterface is designed to fit 800*480 screens, which seem to be a common resolutin for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. - -Whats new: -Higher resolution (800px wide graph) -3 row pixel averaging of sensor data -Fullscreen option for the Spectrometer graph -3rd order polymonial fit of calibration data for accurate measurement. -Improved graph labelling -Labelled measurement cursors -Optional waterfall display for recording spectra changes over time. -Key Bindings for all operations - -All old features have been kept, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. - -For instructions please consult the readme! -''' - - -import cv2 -import time -import numpy as np -from specFunctions import wavelength_to_rgb,savitzky_golay,peakIndexes,readcal,writecal,background,generateGraticule -import base64 -import argparse -from picamera2 import Picamera2 - -parser = argparse.ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("--fullscreen", help="Fullscreen (Native 800*480)",action="store_true") -group.add_argument("--waterfall", help="Enable Waterfall (Windowed only)",action="store_true") -args = parser.parse_args() -dispFullscreen = False -dispWaterfall = False -if args.fullscreen: - print("Fullscreen Spectrometer enabled") - dispFullscreen = True -if args.waterfall: - print("Waterfall display enabled") - dispWaterfall = True - - - -frameWidth = 800 -frameHeight = 600 - -picam2 = Picamera2() -#need to spend more time at: https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf -#but this will do for now! -#min and max microseconds per frame gives framerate. -#30fps (33333, 33333) -#25fps (40000, 40000) - -picamGain = 10.0 - -video_config = picam2.create_video_configuration(main={"format": 'RGB888', "size": (frameWidth, frameHeight)}, controls={"FrameDurationLimits": (33333, 33333)}) -picam2.configure(video_config) -picam2.start() - -#Change analog gain -#picam2.set_controls({"AnalogueGain": 10.0}) #Default 1 -#picam2.set_controls({"Brightness": 0.2}) #Default 0 range -1.0 to +1.0 -#picam2.set_controls({"Contrast": 1.8}) #Default 1 range 0.0-32.0 - - - -title1 = 'PySpectrometer 2 - Spectrograph' -title2 = 'PySpectrometer 2 - Waterfall' -stackHeight = 320+80+80 #height of the displayed CV window (graph+preview+messages) - -if dispWaterfall == True: - #watefall first so spectrum is on top - cv2.namedWindow(title2,cv2.WINDOW_GUI_NORMAL) - cv2.resizeWindow(title2,frameWidth,stackHeight) - cv2.moveWindow(title2,200,200); - -if dispFullscreen == True: - cv2.namedWindow(title1,cv2.WND_PROP_FULLSCREEN) - cv2.setWindowProperty(title1,cv2.WND_PROP_FULLSCREEN,cv2.WINDOW_FULLSCREEN) -else: - cv2.namedWindow(title1,cv2.WINDOW_GUI_NORMAL) - cv2.resizeWindow(title1,frameWidth,stackHeight) - cv2.moveWindow(title1,0,0); - -#settings for peak detect -savpoly = 7 #savgol filter polynomial max val 15 -mindist = 50 #minumum distance between peaks max val 100 -thresh = 20 #Threshold max val 100 - -calibrate = False - -clickArray = [] -cursorX = 0 -cursorY = 0 -def handle_mouse(event,x,y,flags,param): - global clickArray - global cursorX - global cursorY - mouseYOffset = 160 - if event == cv2.EVENT_MOUSEMOVE: - cursorX = x - cursorY = y - if event == cv2.EVENT_LBUTTONDOWN: - mouseX = x - mouseY = y-mouseYOffset - clickArray.append([mouseX,mouseY]) -#listen for click on plot window -cv2.setMouseCallback(title1,handle_mouse) - - -font=cv2.FONT_HERSHEY_SIMPLEX - -intensity = [0] * frameWidth #array for intensity data...full of zeroes - -holdpeaks = False #are we holding peaks? -measure = False #are we measuring? -recPixels = False #are we measuring pixels and recording clicks? - - -#messages -msg1 = "" -saveMsg = "No data saved" - -#blank image for Waterfall -waterfall = np.zeros([320,frameWidth,3],dtype=np.uint8) -waterfall.fill(0) #fill black - -#Go grab the computed calibration data -caldata = readcal(frameWidth) -wavelengthData = caldata[0] -calmsg1 = caldata[1] -calmsg2 = caldata[2] -calmsg3 = caldata[3] - -#generate the craticule data -graticuleData = generateGraticule(wavelengthData) -tens = (graticuleData[0]) -fifties = (graticuleData[1]) - -def snapshot(savedata): - now = time.strftime("%Y%m%d--%H%M%S") - timenow = time.strftime("%H:%M:%S") - imdata1 = savedata[0] - graphdata = savedata[1] - if dispWaterfall == True: - imdata2 = savedata[2] - cv2.imwrite("waterfall-" + now + ".png",imdata2) - cv2.imwrite("spectrum-" + now + ".png",imdata1) - #print(graphdata[0]) #wavelengths - #print(graphdata[1]) #intensities - f = open("Spectrum-"+now+'.csv','w') - f.write('Wavelength,Intensity\r\n') - for x in zip(graphdata[0],graphdata[1]): - f.write(str(x[0])+','+str(x[1])+'\r\n') - f.close() - message = "Last Save: "+timenow - return(message) - - -while True: - # Capture frame-by-frame - frame = picam2.capture_array() - y=int((frameHeight/2)-40) #origin of the vertical crop - #y=200 #origin of the vert crop - x=0 #origin of the horiz crop - h=80 #height of the crop - w=frameWidth #width of the crop - cropped = frame[y:y+h, x:x+w] - bwimage = cv2.cvtColor(cropped,cv2.COLOR_BGR2GRAY) - rows,cols = bwimage.shape - halfway =int(rows/2) - #show our line on the original image - #now a 3px wide region - cv2.line(cropped,(0,halfway-2),(frameWidth,halfway-2),(255,255,255),1) - cv2.line(cropped,(0,halfway+2),(frameWidth,halfway+2),(255,255,255),1) - - #banner image - decoded_data = base64.b64decode(background) - np_data = np.frombuffer(decoded_data,np.uint8) - img = cv2.imdecode(np_data,3) - messages = img - - #blank image for Graph - graph = np.zeros([320,frameWidth,3],dtype=np.uint8) - graph.fill(255) #fill white - - #Display a graticule calibrated with cal data - textoffset = 12 - #vertial lines every whole 10nm - for position in tens: - cv2.line(graph,(position,15),(position,320),(200,200,200),1) - - #vertical lines every whole 50nm - for positiondata in fifties: - cv2.line(graph,(positiondata[0],15),(positiondata[0],320),(0,0,0),1) - cv2.putText(graph,str(positiondata[1])+'nm',(positiondata[0]-textoffset,12),font,0.4,(0,0,0),1, cv2.LINE_AA) - - #horizontal lines - for i in range (320): - if i>=64: - if i%64==0: #suppress the first line then draw the rest... - cv2.line(graph,(0,i),(frameWidth,i),(100,100,100),1) - - #Now process the intensity data and display it - #intensity = [] - for i in range(cols): - #data = bwimage[halfway,i] #pull the pixel data from the halfway mark - #print(type(data)) #numpy.uint8 - #average the data of 3 rows of pixels: - dataminus1 = bwimage[halfway-1,i] - datazero = bwimage[halfway,i] #pull the pixel data from the halfway mark - dataplus1 = bwimage[halfway+1,i] - data = (int(dataminus1)+int(datazero)+int(dataplus1))/3 - data = np.uint8(data) - - - if holdpeaks == True: - if data > intensity[i]: - intensity[i] = data - else: - intensity[i] = data - - if dispWaterfall == True: - #waterfall.... - #data is smoothed at this point!!!!!! - #create an empty array for the data - wdata = np.zeros([1,frameWidth,3],dtype=np.uint8) - index=0 - for i in intensity: - rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wavelenthData array - luminosity = intensity[index]/255 - b = int(round(rgb[0]*luminosity)) - g = int(round(rgb[1]*luminosity)) - r = int(round(rgb[2]*luminosity)) - #print(b,g,r) - #wdata[0,index]=(r,g,b) #fix me!!! how do we deal with this data?? - wdata[0,index]=(r,g,b) - index+=1 - #bright and contrast of final image - contrast = 2.5 - brightness =10 - wdata = cv2.addWeighted( wdata, contrast, wdata, 0, brightness) - waterfall = np.insert(waterfall, 0, wdata, axis=0) #insert line to beginning of array - waterfall = waterfall[:-1].copy() #remove last element from array - - hsv = cv2.cvtColor(waterfall, cv2.COLOR_BGR2HSV) - - - #Draw the intensity data :-) - #first filter if not holding peaks! - - if holdpeaks == False: - intensity = savitzky_golay(intensity,17,savpoly) - intensity = np.array(intensity) - intensity = intensity.astype(int) - holdmsg = "Holdpeaks OFF" - else: - holdmsg = "Holdpeaks ON" - - - #now draw the intensity data.... - index=0 - for i in intensity: - rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wvalenthData array - r = rgb[0] - g = rgb[1] - b = rgb[2] - #or some reason origin is top left. - cv2.line(graph, (index,320), (index,320-i), (b,g,r), 1) - cv2.line(graph, (index,319-i), (index,320-i), (0,0,0), 1,cv2.LINE_AA) - index+=1 - - - #find peaks and label them - textoffset = 12 - thresh = int(thresh) #make sure the data is int. - indexes = peakIndexes(intensity, thres=thresh/max(intensity), min_dist=mindist) - #print(indexes) - for i in indexes: - height = intensity[i] - height = 310-height - wavelength = round(wavelengthData[i],1) - cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,255,255),-1) - cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,0,0),1) - cv2.putText(graph,str(wavelength)+'nm',(i-textoffset,height-3),font,0.4,(0,0,0),1, cv2.LINE_AA) - #flagpoles - cv2.line(graph,(i,height),(i,height+10),(0,0,0),1) - - - if measure == True: - #show the cursor! - cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) - cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) - cv2.putText(graph,str(round(wavelengthData[cursorX],2))+'nm',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) - - if recPixels == True: - #display the points - cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) - cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) - cv2.putText(graph,str(cursorX)+'px',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) - else: - #also make sure the click array stays empty - clickArray = [] - - if clickArray: - for data in clickArray: - mouseX=data[0] - mouseY=data[1] - cv2.circle(graph,(mouseX,mouseY),5,(0,0,0),-1) - #we can display text :-) so we can work out wavelength from x-pos and display it ultimately - cv2.putText(graph,str(mouseX),(mouseX+5,mouseY),cv2.FONT_HERSHEY_SIMPLEX,0.4,(0,0,0)) - - - - - #stack the images and display the spectrum - spectrum_vertical = np.vstack((messages,cropped, graph)) - #dividing lines... - cv2.line(spectrum_vertical,(0,80),(frameWidth,80),(255,255,255),1) - cv2.line(spectrum_vertical,(0,160),(frameWidth,160),(255,255,255),1) - #print the messages - cv2.putText(spectrum_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,calmsg3,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,saveMsg,(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Gain: "+str(picamGain),(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) - #Second column - cv2.putText(spectrum_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Savgol Filter: "+str(savpoly),(640,33),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Label Peak Width: "+str(mindist),(640,51),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Label Threshold: "+str(thresh),(640,69),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.imshow(title1,spectrum_vertical) - - if dispWaterfall == True: - #stack the images and display the waterfall - waterfall_vertical = np.vstack((messages,cropped, waterfall)) - #dividing lines... - cv2.line(waterfall_vertical,(0,80),(frameWidth,80),(255,255,255),1) - cv2.line(waterfall_vertical,(0,160),(frameWidth,160),(255,255,255),1) - #Draw this stuff over the top of the image! - #Display a graticule calibrated with cal data - textoffset = 12 - - #vertical lines every whole 50nm - for positiondata in fifties: - for i in range(162,480): - if i%20 == 0: - cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(0,0,0),2) - cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(255,255,255),1) - cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(0,0,0),2, cv2.LINE_AA) - cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(255,255,255),1, cv2.LINE_AA) - - cv2.putText(waterfall_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(waterfall_vertical,calmsg3,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(waterfall_vertical,saveMsg,(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(waterfall_vertical,"Gain: "+str(picamGain),(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) - - cv2.putText(waterfall_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - - cv2.imshow(title2,waterfall_vertical) - - - keyPress = cv2.waitKey(1) - if keyPress == ord('q'): - break - elif keyPress == ord('h'): - if holdpeaks == False: - holdpeaks = True - elif holdpeaks == True: - holdpeaks = False - elif keyPress == ord("s"): - #package up the data! - graphdata = [] - graphdata.append(wavelengthData) - graphdata.append(intensity) - if dispWaterfall == True: - savedata = [] - savedata.append(spectrum_vertical) - savedata.append(graphdata) - savedata.append(waterfall_vertical) - else: - savedata = [] - savedata.append(spectrum_vertical) - savedata.append(graphdata) - saveMsg = snapshot(savedata) - elif keyPress == ord("c"): - calcomplete = writecal(clickArray) - if calcomplete: - #overwrite wavelength data - #Go grab the computed calibration data - caldata = readcal(frameWidth) - wavelengthData = caldata[0] - calmsg1 = caldata[1] - calmsg2 = caldata[2] - calmsg3 = caldata[3] - #overwrite graticule data - graticuleData = generateGraticule(wavelengthData) - tens = (graticuleData[0]) - fifties = (graticuleData[1]) - elif keyPress == ord("x"): - clickArray = [] - elif keyPress == ord("m"): - recPixels = False #turn off recpixels! - if measure == False: - measure = True - elif measure == True: - measure = False - elif keyPress == ord("p"): - measure = False #turn off measure! - if recPixels == False: - recPixels = True - elif recPixels == True: - recPixels = False - elif keyPress == ord("o"):#sav up - savpoly+=1 - if savpoly >=15: - savpoly=15 - elif keyPress == ord("l"):#sav down - savpoly-=1 - if savpoly <=0: - savpoly=0 - elif keyPress == ord("i"):#Peak width up - mindist+=1 - if mindist >=100: - mindist=100 - elif keyPress == ord("k"):#Peak Width down - mindist-=1 - if mindist <=0: - mindist=0 - elif keyPress == ord("u"):#label thresh up - thresh+=1 - if thresh >=100: - thresh=100 - elif keyPress == ord("j"):#label thresh down - thresh-=1 - if thresh <=0: - thresh=0 - - elif keyPress == ord("t"):#Gain up! - picamGain += 1 - if picamGain >=50: - picamGain = 50.0 - picam2.set_controls({"AnalogueGain": picamGain}) - print("Camera Gain: "+str(picamGain)) - elif keyPress == ord("g"):#Gain down - picamGain -= 1 - if picamGain <=0: - picamGain = 0.0 - picam2.set_controls({"AnalogueGain": picamGain}) - print("Camera Gain: "+str(picamGain)) - - - - -#Everything done -cv2.destroyAllWindows() - - diff --git a/src/PySpectrometer2-USB-v1.0.py b/src/PySpectrometer2-USB-v1.0.py deleted file mode 100644 index 8bb6adc..0000000 --- a/src/PySpectrometer2-USB-v1.0.py +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env python3 - -''' -PySpectrometer2 Les Wright 2022 -https://www.youtube.com/leslaboratory -https://github.com/leswright1977 - -This project is a follow on from: https://github.com/leswright1977/PySpectrometer - -This is a more advanced, but more flexible version of the original program. Tk Has been dropped as the GUI to allow fullscreen mode on Raspberry Pi systems and the iterface is designed to fit 800*480 screens, which seem to be a common resolutin for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. - -Whats new: -Higher resolution (800px wide graph) -3 row pixel averaging of sensor data -Fullscreen option for the Spectrometer graph -3rd order polymonial fit of calibration data for accurate measurement. -Improved graph labelling -Labelled measurement cursors -Optional waterfall display for recording spectra changes over time. -Key Bindings for all operations - -All old features have been kept, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. - -For instructions please consult the readme! - -''' - - -import cv2 -import time -import numpy as np -from specFunctions import wavelength_to_rgb,savitzky_golay,peakIndexes,readcal,writecal,background,generateGraticule -import base64 -import argparse - -parser = argparse.ArgumentParser() -parser.add_argument("--device", type=int, default=0, help="Video Device number e.g. 0, use v4l2-ctl --list-devices") -parser.add_argument("--fps", type=int, default=30, help="Frame Rate e.g. 30") -group = parser.add_mutually_exclusive_group() -group.add_argument("--fullscreen", help="Fullscreen (Native 800*480)",action="store_true") -group.add_argument("--waterfall", help="Enable Waterfall (Windowed only)",action="store_true") -args = parser.parse_args() -dispFullscreen = False -dispWaterfall = False -if args.fullscreen: - print("Fullscreen Spectrometer enabled") - dispFullscreen = True -if args.waterfall: - print("Waterfall display enabled") - dispWaterfall = True - -if args.device: - dev = args.device -else: - dev = 0 - -if args.fps: - fps = args.fps -else: - fps = 30 - -frameWidth = 800 -frameHeight = 600 - - -#init video -cap = cv2.VideoCapture('/dev/video'+str(dev), cv2.CAP_V4L) -#cap = cv2.VideoCapture(0) -print("[info] W, H, FPS") -cap.set(cv2.CAP_PROP_FRAME_WIDTH,frameWidth) -cap.set(cv2.CAP_PROP_FRAME_HEIGHT,frameHeight) -cap.set(cv2.CAP_PROP_FPS,fps) -print(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) -print(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) -print(cap.get(cv2.CAP_PROP_FPS)) -cfps = (cap.get(cv2.CAP_PROP_FPS)) - - -title1 = 'PySpectrometer 2 - Spectrograph' -title2 = 'PySpectrometer 2 - Waterfall' -stackHeight = 320+80+80 #height of the displayed CV window (graph+preview+messages) - -if dispWaterfall == True: - #watefall first so spectrum is on top - cv2.namedWindow(title2,cv2.WINDOW_GUI_NORMAL) - cv2.resizeWindow(title2,frameWidth,stackHeight) - cv2.moveWindow(title2,200,200); - -if dispFullscreen == True: - cv2.namedWindow(title1,cv2.WND_PROP_FULLSCREEN) - cv2.setWindowProperty(title1,cv2.WND_PROP_FULLSCREEN,cv2.WINDOW_FULLSCREEN) -else: - cv2.namedWindow(title1,cv2.WINDOW_GUI_NORMAL) - cv2.resizeWindow(title1,frameWidth,stackHeight) - cv2.moveWindow(title1,0,0); - -#settings for peak detect -savpoly = 7 #savgol filter polynomial max val 15 -mindist = 50 #minumum distance between peaks max val 100 -thresh = 20 #Threshold max val 100 - -calibrate = False - -clickArray = [] -cursorX = 0 -cursorY = 0 -def handle_mouse(event,x,y,flags,param): - global clickArray - global cursorX - global cursorY - mouseYOffset = 160 - if event == cv2.EVENT_MOUSEMOVE: - cursorX = x - cursorY = y - if event == cv2.EVENT_LBUTTONDOWN: - mouseX = x - mouseY = y-mouseYOffset - clickArray.append([mouseX,mouseY]) -#listen for click on plot window -cv2.setMouseCallback(title1,handle_mouse) - - -font=cv2.FONT_HERSHEY_SIMPLEX - -intensity = [0] * frameWidth #array for intensity data...full of zeroes - -holdpeaks = False #are we holding peaks? -measure = False #are we measuring? -recPixels = False #are we measuring pixels and recording clicks? - - -#messages -msg1 = "" -saveMsg = "No data saved" - -#blank image for Waterfall -waterfall = np.zeros([320,frameWidth,3],dtype=np.uint8) -waterfall.fill(0) #fill black - -#Go grab the computed calibration data -caldata = readcal(frameWidth) -wavelengthData = caldata[0] -calmsg1 = caldata[1] -calmsg2 = caldata[2] -calmsg3 = caldata[3] - -#generate the craticule data -graticuleData = generateGraticule(wavelengthData) -tens = (graticuleData[0]) -fifties = (graticuleData[1]) - -def snapshot(savedata): - now = time.strftime("%Y%m%d--%H%M%S") - timenow = time.strftime("%H:%M:%S") - imdata1 = savedata[0] - graphdata = savedata[1] - if dispWaterfall == True: - imdata2 = savedata[2] - cv2.imwrite("waterfall-" + now + ".png",imdata2) - cv2.imwrite("spectrum-" + now + ".png",imdata1) - #print(graphdata[0]) #wavelengths - #print(graphdata[1]) #intensities - f = open("Spectrum-"+now+'.csv','w') - f.write('Wavelength,Intensity\r\n') - for x in zip(graphdata[0],graphdata[1]): - f.write(str(x[0])+','+str(x[1])+'\r\n') - f.close() - message = "Last Save: "+timenow - return(message) - -while(cap.isOpened()): - # Capture frame-by-frame - ret, frame = cap.read() - - if ret == True: - y=int((frameHeight/2)-40) #origin of the vertical crop - #y=200 #origin of the vert crop - x=0 #origin of the horiz crop - h=80 #height of the crop - w=frameWidth #width of the crop - cropped = frame[y:y+h, x:x+w] - bwimage = cv2.cvtColor(cropped,cv2.COLOR_BGR2GRAY) - rows,cols = bwimage.shape - halfway =int(rows/2) - #show our line on the original image - #now a 3px wide region - cv2.line(cropped,(0,halfway-2),(frameWidth,halfway-2),(255,255,255),1) - cv2.line(cropped,(0,halfway+2),(frameWidth,halfway+2),(255,255,255),1) - - #banner image - decoded_data = base64.b64decode(background) - np_data = np.frombuffer(decoded_data,np.uint8) - img = cv2.imdecode(np_data,3) - messages = img - - #blank image for Graph - graph = np.zeros([320,frameWidth,3],dtype=np.uint8) - graph.fill(255) #fill white - - #Display a graticule calibrated with cal data - textoffset = 12 - #vertial lines every whole 10nm - for position in tens: - cv2.line(graph,(position,15),(position,320),(200,200,200),1) - - #vertical lines every whole 50nm - for positiondata in fifties: - cv2.line(graph,(positiondata[0],15),(positiondata[0],320),(0,0,0),1) - cv2.putText(graph,str(positiondata[1])+'nm',(positiondata[0]-textoffset,12),font,0.4,(0,0,0),1, cv2.LINE_AA) - - #horizontal lines - for i in range (320): - if i>=64: - if i%64==0: #suppress the first line then draw the rest... - cv2.line(graph,(0,i),(frameWidth,i),(100,100,100),1) - - #Now process the intensity data and display it - #intensity = [] - for i in range(cols): - #data = bwimage[halfway,i] #pull the pixel data from the halfway mark - #print(type(data)) #numpy.uint8 - #average the data of 3 rows of pixels: - dataminus1 = bwimage[halfway-1,i] - datazero = bwimage[halfway,i] #pull the pixel data from the halfway mark - dataplus1 = bwimage[halfway+1,i] - data = (int(dataminus1)+int(datazero)+int(dataplus1))/3 - data = np.uint8(data) - - - if holdpeaks == True: - if data > intensity[i]: - intensity[i] = data - else: - intensity[i] = data - - if dispWaterfall == True: - #waterfall.... - #data is smoothed at this point!!!!!! - #create an empty array for the data - wdata = np.zeros([1,frameWidth,3],dtype=np.uint8) - index=0 - for i in intensity: - rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wavelenthData array - luminosity = intensity[index]/255 - b = int(round(rgb[0]*luminosity)) - g = int(round(rgb[1]*luminosity)) - r = int(round(rgb[2]*luminosity)) - #print(b,g,r) - #wdata[0,index]=(r,g,b) #fix me!!! how do we deal with this data?? - wdata[0,index]=(r,g,b) - index+=1 - waterfall = np.insert(waterfall, 0, wdata, axis=0) #insert line to beginning of array - waterfall = waterfall[:-1].copy() #remove last element from array - - hsv = cv2.cvtColor(waterfall, cv2.COLOR_BGR2HSV) - - - - #Draw the intensity data :-) - #first filter if not holding peaks! - - if holdpeaks == False: - intensity = savitzky_golay(intensity,17,savpoly) - intensity = np.array(intensity) - intensity = intensity.astype(int) - holdmsg = "Holdpeaks OFF" - else: - holdmsg = "Holdpeaks ON" - - - #now draw the intensity data.... - index=0 - for i in intensity: - rgb = wavelength_to_rgb(round(wavelengthData[index]))#derive the color from the wvalenthData array - r = rgb[0] - g = rgb[1] - b = rgb[2] - #or some reason origin is top left. - cv2.line(graph, (index,320), (index,320-i), (b,g,r), 1) - cv2.line(graph, (index,319-i), (index,320-i), (0,0,0), 1,cv2.LINE_AA) - index+=1 - - - #find peaks and label them - textoffset = 12 - thresh = int(thresh) #make sure the data is int. - indexes = peakIndexes(intensity, thres=thresh/max(intensity), min_dist=mindist) - #print(indexes) - for i in indexes: - height = intensity[i] - height = 310-height - wavelength = round(wavelengthData[i],1) - cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,255,255),-1) - cv2.rectangle(graph,((i-textoffset)-2,height),((i-textoffset)+60,height-15),(0,0,0),1) - cv2.putText(graph,str(wavelength)+'nm',(i-textoffset,height-3),font,0.4,(0,0,0),1, cv2.LINE_AA) - #flagpoles - cv2.line(graph,(i,height),(i,height+10),(0,0,0),1) - - - if measure == True: - #show the cursor! - cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) - cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) - cv2.putText(graph,str(round(wavelengthData[cursorX],2))+'nm',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) - - if recPixels == True: - #display the points - cv2.line(graph,(cursorX,cursorY-140),(cursorX,cursorY-180),(0,0,0),1) - cv2.line(graph,(cursorX-20,cursorY-160),(cursorX+20,cursorY-160),(0,0,0),1) - cv2.putText(graph,str(cursorX)+'px',(cursorX+5,cursorY-165),font,0.4,(0,0,0),1, cv2.LINE_AA) - else: - #also make sure the click array stays empty - clickArray = [] - - if clickArray: - for data in clickArray: - mouseX=data[0] - mouseY=data[1] - cv2.circle(graph,(mouseX,mouseY),5,(0,0,0),-1) - #we can display text :-) so we can work out wavelength from x-pos and display it ultimately - cv2.putText(graph,str(mouseX),(mouseX+5,mouseY),cv2.FONT_HERSHEY_SIMPLEX,0.4,(0,0,0)) - - - - - #stack the images and display the spectrum - spectrum_vertical = np.vstack((messages,cropped, graph)) - #dividing lines... - cv2.line(spectrum_vertical,(0,80),(frameWidth,80),(255,255,255),1) - cv2.line(spectrum_vertical,(0,160),(frameWidth,160),(255,255,255),1) - #print the messages - cv2.putText(spectrum_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,calmsg3,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Framerate: "+str(cfps),(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,saveMsg,(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) - #Second column - cv2.putText(spectrum_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Savgol Filter: "+str(savpoly),(640,33),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Label Peak Width: "+str(mindist),(640,51),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(spectrum_vertical,"Label Threshold: "+str(thresh),(640,69),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.imshow(title1,spectrum_vertical) - - if dispWaterfall == True: - #stack the images and display the waterfall - waterfall_vertical = np.vstack((messages,cropped, waterfall)) - #dividing lines... - cv2.line(waterfall_vertical,(0,80),(frameWidth,80),(255,255,255),1) - cv2.line(waterfall_vertical,(0,160),(frameWidth,160),(255,255,255),1) - #Draw this stuff over the top of the image! - #Display a graticule calibrated with cal data - textoffset = 12 - - #vertical lines every whole 50nm - for positiondata in fifties: - for i in range(162,480): - if i%20 == 0: - cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(0,0,0),2) - cv2.line(waterfall_vertical,(positiondata[0],i),(positiondata[0],i+1),(255,255,255),1) - cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(0,0,0),2, cv2.LINE_AA) - cv2.putText(waterfall_vertical,str(positiondata[1])+'nm',(positiondata[0]-textoffset,475),font,0.4,(255,255,255),1, cv2.LINE_AA) - - cv2.putText(waterfall_vertical,calmsg1,(490,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(waterfall_vertical,calmsg2,(490,33),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(waterfall_vertical,calmsg3,(490,51),font,0.4,(0,255,255),1, cv2.LINE_AA) - cv2.putText(waterfall_vertical,saveMsg,(490,69),font,0.4,(0,255,255),1, cv2.LINE_AA) - - cv2.putText(waterfall_vertical,holdmsg,(640,15),font,0.4,(0,255,255),1, cv2.LINE_AA) - - cv2.imshow(title2,waterfall_vertical) - - - keyPress = cv2.waitKey(1) - if keyPress == ord('q'): - break - elif keyPress == ord('h'): - if holdpeaks == False: - holdpeaks = True - elif holdpeaks == True: - holdpeaks = False - elif keyPress == ord("s"): - #package up the data! - graphdata = [] - graphdata.append(wavelengthData) - graphdata.append(intensity) - if dispWaterfall == True: - savedata = [] - savedata.append(spectrum_vertical) - savedata.append(graphdata) - savedata.append(waterfall_vertical) - else: - savedata = [] - savedata.append(spectrum_vertical) - savedata.append(graphdata) - saveMsg = snapshot(savedata) - elif keyPress == ord("c"): - calcomplete = writecal(clickArray) - if calcomplete: - #overwrite wavelength data - #Go grab the computed calibration data - caldata = readcal(frameWidth) - wavelengthData = caldata[0] - calmsg1 = caldata[1] - calmsg2 = caldata[2] - calmsg3 = caldata[3] - #overwrite graticule data - graticuleData = generateGraticule(wavelengthData) - tens = (graticuleData[0]) - fifties = (graticuleData[1]) - elif keyPress == ord("x"): - clickArray = [] - elif keyPress == ord("m"): - recPixels = False #turn off recpixels! - if measure == False: - measure = True - elif measure == True: - measure = False - elif keyPress == ord("p"): - measure = False #turn off measure! - if recPixels == False: - recPixels = True - elif recPixels == True: - recPixels = False - elif keyPress == ord("o"):#sav up - savpoly+=1 - if savpoly >=15: - savpoly=15 - elif keyPress == ord("l"):#sav down - savpoly-=1 - if savpoly <=0: - savpoly=0 - elif keyPress == ord("i"):#Peak width up - mindist+=1 - if mindist >=100: - mindist=100 - elif keyPress == ord("k"):#Peak Width down - mindist-=1 - if mindist <=0: - mindist=0 - elif keyPress == ord("u"):#label thresh up - thresh+=1 - if thresh >=100: - thresh=100 - elif keyPress == ord("j"):#label thresh down - thresh-=1 - if thresh <=0: - thresh=0 - else: - break - - -#Everything done, release the vid -cap.release() - -cv2.destroyAllWindows() - - diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..ed3cdb9 --- /dev/null +++ b/src/README.md @@ -0,0 +1,27 @@ +# PySpectrometer Source Code + +This directory contains the source code. + +## Main Entry Point + +- `pyspectrometer_picam2.py`: The main script that orchestrates the application, loading configuration, setting up camera and GUI, and running the capture loop in different modes. + +## Submodules + +- `calibration/`: Handles spectrometer calibration mapping pixel positions to wavelengths using polynomial fitting. +- `config/`: Manages configuration parameters loaded from JSON files. +- `gui/`: Provides the graphical user interface using OpenCV, including image display and mouse interaction. +- `raspberry_camera/`: Sets up and configures the Raspberry Pi camera for video capture. +- `state_manager/`: Manages the application's state, including spectral data, operating modes, and processing parameters. +- `capture_iteration/`: Contains modules for different capture modes: + - `emittance/`: Handles emittance spectroscopy capture and processing. + - TODO: + - `transmittance/`: Manages transmittance spectroscopy capture. + - `waterfall/`: Implements waterfall display mode for spectral data visualization. + +## Main used Dependencies + +- OpenCV +- NumPy +- Picamera2 (for Raspberry Pi) +- Pydantic (for configuration validation) diff --git a/src/calibration/AGENTS.md b/src/calibration/AGENTS.md new file mode 100644 index 0000000..0bd977d --- /dev/null +++ b/src/calibration/AGENTS.md @@ -0,0 +1,22 @@ +# Calibration Module + +This module handles the calibration of the spectrometer: mapping pixel positions to wavelengths using polynomial fitting. + +## Files + +- `read_calibration.py`: Reads calibration data from a file and performs polynomial fitting to create a pixel-to-wavelength mapping. + - Supports second-order (3 points) and third-order (>3 points) polynomial fits. + - Calculates R-squared for fit quality assessment in third-order fits. + - Returns a wavelength array for each pixel and calibration status messages. + +- `write_calibration.py`: Writes new calibration data to a file based on user-provided pixel positions and wavelengths. + - Prompts the user to input wavelengths for clicked pixel positions. + - Saves the data in a comma-separated format to the calibration file. + +## Usage + +Calibration involves clicking on known spectral lines in the image and entering their wavelengths. The module then fits a polynomial to map pixels to wavelengths accurately. + +## TODO +- Give the option to use higher of 3rd polynomial fitting. After all, the refactor of the old PySpectrometer2 now provides extra available overhead for these type of calculations +- As noted in storage/README.md, migrate the inadequate .txt file to a proper .csv with descriptive headers to improve professionalism and readability diff --git a/src/calibration/read_calibration.py b/src/calibration/read_calibration.py new file mode 100644 index 0000000..22f76ae --- /dev/null +++ b/src/calibration/read_calibration.py @@ -0,0 +1,164 @@ +import numpy as np + + +def read_calibration_data(camera_frame_width: int, calibration_data_file_path: str) -> tuple[np.ndarray, dict[str, str]]: + """ + Reads calibration data from 'caldata.txt', fits a polynomial to map pixel positions to wavelengths, + and generates a wavelength array for the spectrometer image. + + The function supports both second-order (for 3 calibration points) and third-order (for >3 points) polynomial fitting. + It also provides calibration status messages and, for third-order fits, calculates the R-squared value to assess fit quality. + + Args: + camera_frame_width (int): The width of the camera frame (number of pixel columns). + + Returns: + tuple: ( + px_to_wavelength_array (np.ndarray of float64): Wavelength value for each pixel column, + calibration_messages (dict of messages) + ) + + Algorithm: + 1. Reads pixel positions and corresponding wavelengths from 'caldata.txt'. + 2. Validates calibration data; loads defaults if invalid or insufficient. + 3. Fits a polynomial (2nd or 3rd order) to the calibration points. + 4. Generates a wavelength value for each pixel column using the fitted polynomial. + 5. For >3 calibration points, calculates R-squared to assess fit quality. + 6. Returns the wavelength array and calibration status messages. + """ + + errors = 0 + calibration_messages: dict[str, str] = {} + + try: + with open(calibration_data_file_path, "r") as file: + # read both the pixel numbers line, which is the first, and the wavelengths line, the second one, and put them into two numpy arrays. + lines = file.readlines() + if not lines: + raise FileNotFoundError("Calibration data file is empty.") + + pixels_raw_line = lines[0].strip() + wavelengths_raw_line = lines[1].strip() + except FileNotFoundError: + # If the file does not exist, we load placeholder data + pixels_raw_line = "0,400,800" + wavelengths_raw_line = "380,560,750" + errors = 1 + + pixels_str_list = pixels_raw_line.split(",") + pixels_int_array = np.array([int(i) for i in pixels_str_list if i], dtype=np.int32) + + wavelengths_str_list = wavelengths_raw_line.split(",") + wavelengths_float_array = np.array([float(i) for i in wavelengths_str_list if i], dtype=np.float64) + + if len(pixels_int_array) != len(wavelengths_float_array): + # The Calibration points are of unequal length! + errors = 1 + if len(pixels_int_array) < 3 or len(wavelengths_float_array) < 3: + # The Cal data contains less than 3 pixels or wavelengths! + errors = 1 + + if errors == 1: + # Load placeholder data + pixels_int_array = np.array([0, 400, 800], dtype=np.int32) + wavelengths_float_array = np.array([380, 560, 750], dtype=np.float64) + + # create an array for the computed wavelengths (pixel-to-wavelength mapping) + px_to_wavelength_array: np.ndarray = np.zeros(camera_frame_width, dtype=np.float64) + + if len(pixels_int_array) == 3: + _second_order_polynomial_fit( + errors=errors, + pixels_array=pixels_int_array, + camera_frame_width=camera_frame_width, + wavelengths_array=wavelengths_float_array, + calibration_messages=calibration_messages, + px_to_wavelength_array=px_to_wavelength_array, + ) + + if len(pixels_int_array) > 3: + _third_order_polynomial_fit( + pixels_array=pixels_int_array, + camera_frame_width=camera_frame_width, + wavelengths_array=wavelengths_float_array, + calibration_messages=calibration_messages, + px_to_wavelength_array=px_to_wavelength_array, + ) + + print(calibration_messages) + + return (px_to_wavelength_array, calibration_messages) # type: ignore + + +def _second_order_polynomial_fit( + errors: int, + pixels_array: np.ndarray, + wavelengths_array: np.ndarray, + camera_frame_width: int, + calibration_messages: dict, + px_to_wavelength_array: np.ndarray, +) -> None: + # "Calculating second order polynomial..." + coefficients = np.poly1d(np.polyfit(pixels_array, wavelengths_array, 2)) + C1 = coefficients[2] + C2 = coefficients[1] + C3 = coefficients[0] + + # Generate wavelength map + for pixel in range(camera_frame_width): + wavelength_calculated_value = (C1 * pixel**2) + (C2 * pixel) + C3 + wavelength_calculated_value = round(wavelength_calculated_value, 6) + px_to_wavelength_array[pixel] = wavelength_calculated_value + + if errors == 1: + calibration_messages["calibration_status"] = "R^2: 0" + calibration_messages["which_points_we_used"] = "Placeholders loaded" + calibration_messages["type_of_poly_fit"] = "Perform Calibration!" + else: + calibration_messages["calibration_status"] = "R^2: N/A" + calibration_messages["which_points_we_used"] = "Using 3 calibration points" + calibration_messages["type_of_poly_fit"] = "2nd Order Polyfit" + # this alerts that only 3 wavelengths is inaccurate + + +def _third_order_polynomial_fit( + pixels_array: np.ndarray, + wavelengths_array: np.ndarray, + camera_frame_width: int, + calibration_messages: dict, + px_to_wavelength_array: np.ndarray, +) -> None: + # Calculating third order polynomial... + coefficients = np.poly1d(np.polyfit(pixels_array, wavelengths_array, 3)) + + C1 = coefficients[3] + C2 = coefficients[2] + C3 = coefficients[1] + C4 = coefficients[0] + + # Generating Wavelength Data + for pixel in range(camera_frame_width): + wavelength = (C1 * pixel**3) + (C2 * pixel**2) + (C3 * pixel) + C4 + wavelength = round(wavelength, 6) + px_to_wavelength_array[pixel] = wavelength + + # final job, we need to compare all the recorded wavelengths with predicted wavelengths + predicted_wavelengths_temp = [] + + # iterate over the original pixelnumber array and predict results + for px in pixels_array: + y = (C1 * px**3) + (C2 * px**2) + (C3 * px) + C4 + predicted_wavelengths_temp.append(y) + + # Convert to numpy array for consistency + predicted_wavelengths_with_calibration_data = np.array(predicted_wavelengths_temp, dtype=np.float64) + + # calculate 2 squared of the result + # if this is close to 1 we are all good! + correlation_matrix = np.corrcoef(wavelengths_array, predicted_wavelengths_with_calibration_data) + correlation = float(correlation_matrix[0, 1]) + r_square = float(correlation**2) + + calibration_messages["calibration_status"] = f"R^2: {float(r_square):.5f}" + calibration_messages["which_points_we_used"] = f"Using {len(pixels_array)} cal points" + calibration_messages["type_of_poly_fit"] = "3rd Order Polyfit" diff --git a/src/calibration/write_calibration.py b/src/calibration/write_calibration.py new file mode 100644 index 0000000..44f16fa --- /dev/null +++ b/src/calibration/write_calibration.py @@ -0,0 +1,33 @@ +from typing import Any + + +def write_calibration_data(click_array_from_cv2: Any, calibration_data_file_path: str) -> bool: + + pixels_int_list = [] + wavelengths_float_list = [] + + # Enter known wavelengths for observed pixels! + for i in click_array_from_cv2: + pixel = i[0] + + wv_is_valid = False + while not wv_is_valid: + inputted_wavelength_str = input("Enter wavelength for: " + str(pixel) + "px:") + try: + inputted_wavelength_float = float(inputted_wavelength_str) + break + except Exception: + continue + + pixels_int_list.append(pixel) + wavelengths_float_list.append(inputted_wavelength_float) # type: ignore + + pxdata = ",".join(map(str, pixels_int_list)) # convert array to string + wldata = ",".join(map(str, wavelengths_float_list)) # type: ignore # convert array to string + + with open(calibration_data_file_path, "w") as file: + file.write(pxdata + "\r\n") + file.write(wldata + "\r\n") + return True + + return False diff --git a/src/capture_iteration/emittance/capture_emittance_iteration.py b/src/capture_iteration/emittance/capture_emittance_iteration.py new file mode 100644 index 0000000..f372ff3 --- /dev/null +++ b/src/capture_iteration/emittance/capture_emittance_iteration.py @@ -0,0 +1,140 @@ +from typing import Any +from time import sleep + +from src.capture_iteration.emittance.keyboard_events import listen_to_keyboard_events # type: ignore +from src.capture_iteration.emittance.process_spectrum_intensities import ( + extract_spectral_intensities, + filter_intensity_with_savitzky, +) +from src.state_manager.state_manager import SpectrometerStateManager +from src.calibration.read_calibration import read_calibration_data +from src.gui.display_image import generate_graticule_for_gui +from src.config.config import ApplicationConstants +from src.gui.display_image import ( # type: ignore + show_px_at_mouse_position_for_calibration, + show_wavelength_at_mouse_position, + stack_images_and_display_spectrum, + crop_capture_to_area_of_interest, + draw_band_lines_on_display_image, + draw_graticule_on_display_image, + draw_colored_spectrogram, + draw_clicks_as_circles, +) + + +# This function represents one iteration of the capture process: it's the process from taking the picture to processing it +def capture_emittance_iteration( + picam2: Any, cv2: Any, static_elements: dict, constants: ApplicationConstants, state_manager: SpectrometerStateManager +) -> bool: + # Deploy the static constants into usable objects for readability. These can't and won't be modified by the user + GUI_FONT = constants.gui_font + CAMERA_FRAME_WIDTH = constants.camera_frame_width + CAMERA_FRAME_HEIGHT = constants.camera_frame_height + SPECTROGRAPH_WIN_NAME = constants.spectrograph_win_name + CROPPED_IMG_HEIGHT = constants.cropped_image_height + static_elements["graph"].fill(255) # Ensure the graph is white. This will erase previous mouse cross + + # (0/10) Check if we just changed the spectrometer + if state_manager.spectrometer_mode_just_changed(): + # Load calibration data through state manager (if it's start up, it will be the default) + px_to_wavelength_array, calibration_messages = read_calibration_data( + camera_frame_width=constants.camera_frame_width, calibration_data_file_path=constants.calibration_data_file_path + ) + + graticule_data_dict = generate_graticule_for_gui(px_to_wavelength_array) + state_manager.update_calibration_data(px_to_wavelength_array, calibration_messages, graticule_data_dict) + state_manager.increment_capture_iteration() + + # (1/10) Capture a live image, and allow for testing mode + if not state_manager.is_testing_mode(): + raw_captured_image = picam2.capture_array() + else: + # In testing mode, we use a static image for testing purposes + raw_captured_image = cv2.imread(constants.testing_image_path) + sleep(0.5) + + # (2/10) Crop the image to the area of interest. This image has color, and is the one that will be displayed visually + cropped_image = crop_capture_to_area_of_interest( + camera_frame_height=CAMERA_FRAME_HEIGHT, + camera_frame_width=CAMERA_FRAME_WIDTH, + raw_captured_image=raw_captured_image, + cropped_image_height=CROPPED_IMG_HEIGHT, + ) + + # (3/10) Convert the coloured cropped_image image to a grayscale image which will be used for data extraction + bw_cropped_image = cv2.cvtColor(cropped_image, cv2.COLOR_BGR2GRAY) + number_of_pixel_rows, number_of_pixel_columns = bw_cropped_image.shape + halfway_height_px = number_of_pixel_rows // 2 + + # (4/10) Draw visual guide lines on the display image to show the 3px analysis band + draw_band_lines_on_display_image( + cv2=cv2, cropped_image=cropped_image, halfway_height_px=halfway_height_px, camera_frame_width=CAMERA_FRAME_WIDTH + ) + + # (5/10) Display a graticule aligned with calibration data + draw_graticule_on_display_image( + cv2=cv2, + text_offset=12, + gui_font=GUI_FONT, + static_elements=static_elements, + camera_frame_width=CAMERA_FRAME_WIDTH, + graticule_data_dict=state_manager.get_graticule_data(), + ) + + # (5/10) Extract the spectral intensities from the grayscale image + extract_spectral_intensities( + bw_cropped_image=bw_cropped_image, + halfway_height_px=halfway_height_px, + number_of_pixel_columns=number_of_pixel_columns, + state_manager=state_manager, + ) + + # (6/10) Filter the intensity data and update it (only filters if not in peak-hold mode) + filter_intensity_with_savitzky(state_manager=state_manager) + + # (7/10) Draw the colored spectrogram with the intensity array for the GUI + draw_colored_spectrogram( + cv2=cv2, + state_manager=state_manager, + static_elements=static_elements, + gui_font=GUI_FONT, + ) + + # (8/10) This section handles mouse interactions in the GUI + # When a keyboard binding is clicked, it will display a cursor with the wavelength at the mouse position. + show_wavelength_at_mouse_position(cv2=cv2, gui_font=GUI_FONT, state_manager=state_manager, static_elements=static_elements) + + # When the user is calibrating, it will display a cursor with the pixel at the mouse position. + show_px_at_mouse_position_for_calibration( + cv2=cv2, gui_font=GUI_FONT, state_manager=state_manager, static_elements=static_elements + ) + + # Draw each click as a circle on the spectrogram when calibrating + draw_clicks_as_circles( + cv2=cv2, + static_elements=static_elements, + state_manager=state_manager, + ) + + # (9/10) Stack the images and display the spectrum on the GUI finally + spectrum_vertical = stack_images_and_display_spectrum( + cv2=cv2, + static_elements=static_elements, + cropped_image=cropped_image, + state_manager=state_manager, + gui_font=GUI_FONT, + camera_frame_width=CAMERA_FRAME_WIDTH, + spectrograph_win_name=SPECTROGRAPH_WIN_NAME, + ) + + # (10/10) Listen to keyboard events for user interactions. This allows direct interaction with the state of + # the spectrometer, such as toggling peak hold, measuring mode, and calibration mode. + return listen_to_keyboard_events( + cv2=cv2, + picam2=picam2, + spectrum_vertical=spectrum_vertical, + state_manager=state_manager, + camera_frame_width=CAMERA_FRAME_WIDTH, + snapshot_folder=constants.snapshot_folder, + calibration_data_file_path=constants.calibration_data_file_path + ) diff --git a/src/capture_iteration/emittance/keyboard_events.py b/src/capture_iteration/emittance/keyboard_events.py new file mode 100644 index 0000000..ed18f1c --- /dev/null +++ b/src/capture_iteration/emittance/keyboard_events.py @@ -0,0 +1,103 @@ +from typing import Any +import numpy as np + +from src.capture_iteration.emittance.save_snapshot import save_snapshot_of_current_spectrogram # type: ignore +from src.calibration.write_calibration import write_calibration_data +from src.state_manager.state_manager import SpectrometerStateManager +from src.calibration.read_calibration import read_calibration_data +from src.gui.display_image import generate_graticule_for_gui + + +def listen_to_keyboard_events( + cv2: Any, + picam2: Any, + spectrum_vertical: np.ndarray, + state_manager: SpectrometerStateManager, + camera_frame_width: int, + calibration_data_file_path: str, + snapshot_folder: str +): + """ + Gomills: I really don't understand deeply what the keyboard events do, I just refactored it.""" + + # Listen to keyboard events + key_press = cv2.waitKey(1) + if key_press == ord("q"): # tested + return False + + elif key_press == ord("h"): + state_manager.toggle_peak_hold() + + elif key_press == ord("s"): # tested + # package up the data! + save_data = { + "px_to_wavelength_array": state_manager.get_calibration_data()["px_to_wavelength_array"], # type: ignore + "intensity_array": state_manager.get_intensity_array(), + "spectrum_vertical": spectrum_vertical + } + + message_to_set = ( + save_snapshot_of_current_spectrogram(save_data=save_data, cv2=cv2, snapshot_folder=snapshot_folder) + ) + + state_manager.set_save_status(message_to_set) + + elif key_press == ord("c"): # tested + calibration_success = write_calibration_data( + click_array_from_cv2=state_manager.get_click_array(), + calibration_data_file_path=calibration_data_file_path) + if calibration_success: + # overwrite wavelength data + # Go grab the computed calibration data + px_to_wavelength_array, messages = read_calibration_data( + camera_frame_width=camera_frame_width, + calibration_data_file_path=calibration_data_file_path) + new_graticule_data_dict = generate_graticule_for_gui( + px_to_wavelength_array=px_to_wavelength_array, + ) + state_manager.update_calibration_data( + px_to_wavelength=px_to_wavelength_array, + messages=messages, + graticule_data=new_graticule_data_dict + ) + else: + state_manager.set_save_status("Calibration failed.") + + + elif key_press == ord("x"): # tested + state_manager.clear_calibration_clicks() + elif key_press == ord("m"): # tested + # This one just displays a cursor, it's too complicated the + # nomenclature around. It just displays a cursor with the wavelength instead + # of the px with the calibration toggle p + state_manager.toggle_measuring_mode() + elif key_press == ord("p"): # tested + state_manager.toggle_calibration_mode() + elif key_press == ord("o"): # tested + state_manager.adjust_savgol_filter(1) + elif key_press == ord("l"): # tested + state_manager.adjust_savgol_filter(-1) + elif key_press == ord("i"): # tested + state_manager.adjust_peak_distance(1) + elif key_press == ord("k"): # tested + state_manager.adjust_peak_distance(-1) + elif key_press == ord("u"): # tested + state_manager.adjust_threshold(1) + elif key_press == ord("j"): # tested + state_manager.adjust_threshold(-1) + elif key_press == ord("v"): + state_manager.adjust_camera_gain(1.0) + picam2.set_controls({"AnalogueGain": state_manager.get_camera_gain()}) + elif key_press == ord("g"): + state_manager.adjust_camera_gain(-1.0) + picam2.set_controls({"AnalogueGain": state_manager.get_camera_gain()}) + elif key_press == ord("e"): + state_manager.reset_before_mode_change() + state_manager.change_spectrometer_mode("emittance_spectrometer") + elif key_press == ord("t"): + state_manager.reset_before_mode_change() + state_manager.change_spectrometer_mode("transmittance_spectrometer") + elif key_press == ord("w"): + state_manager.reset_before_mode_change() + state_manager.change_spectrometer_mode("waterfall_display") + return True diff --git a/src/capture_iteration/emittance/load_static.py b/src/capture_iteration/emittance/load_static.py new file mode 100644 index 0000000..6f79d46 --- /dev/null +++ b/src/capture_iteration/emittance/load_static.py @@ -0,0 +1,30 @@ +from typing import Any +import numpy as np + +def load_static_elements(cv2: Any, camera_frame_width: int) -> dict: + """ + Loads static UI elements for display, including a banner image and a blank graph. + Args: + cv2 (Any): The OpenCV module. + camera_settings (CameraSettings): An object containing camera configuration. + background (str, optional): Base64-encoded string representing the banner image. Defaults to BACKGROUND. + Returns: + dict: A dictionary containing: + - "banner_image": The decoded banner image as a NumPy array. + - "graph": A blank white graph image as a NumPy array with dimensions [320, camera_frame_width, 3]. + """ + + # Here we: 1.- decode the banner image 2.- convert it to an array 3.- add it to messages for display + with open("src/gui/banner_image.png", "rb") as f: + decoded_banner_image_data = f.read() + # Convert the decoded image data to a NumPy array + np_data = np.frombuffer(decoded_banner_image_data, np.uint8) + banner_image = cv2.imdecode(np_data, 3) + + # blank image. Graph has specific proportions, one of which 320 is, but this is hardcoded (fix later) + graph = np.zeros([320, camera_frame_width, 3], dtype=np.uint8) + graph.fill(255) # fill white + + static_elements = {"banner_image": banner_image, "graph": graph} + + return static_elements diff --git a/src/capture_iteration/emittance/process_spectrum_intensities.py b/src/capture_iteration/emittance/process_spectrum_intensities.py new file mode 100644 index 0000000..37012a4 --- /dev/null +++ b/src/capture_iteration/emittance/process_spectrum_intensities.py @@ -0,0 +1,57 @@ +from typing import Any +import numpy as np + +from src.capture_iteration.emittance.spectroscopy_calculations import savitzky_golay # type: ignore +from src.state_manager.state_manager import SpectrometerStateManager + +def extract_spectral_intensities( + bw_cropped_image: Any, + halfway_height_px: int, + number_of_pixel_columns: int, + state_manager: SpectrometerStateManager, +): + """ + Extracts spectral intensity data by averaging a 3-pixel vertical band across each column. + + For each column, averages the pixel values at halfway_height_px-1, halfway_height_px, + and halfway_height_px+1 to generate the spectral intensity for that wavelength position. + """ + + # Get the [average array] from the 3 rows + rows = bw_cropped_image[halfway_height_px - 1 : halfway_height_px + 1 + 1, :] # stop index is exclusive in python, that's why +1 + 1 + averaged_intensity_uint8 = np.mean(rows.astype(np.int32), axis=0).astype(np.uint8) + + for pxcol in range(number_of_pixel_columns): + + # By validating we mean to update intensity only if we are in peak-hold mode. Otherwise, it auto-returns the value + validated_intensity = state_manager.validate_intensity_extraction( + peak_hold_mode=state_manager.is_peak_hold_active(), + current_intensity=averaged_intensity_uint8[pxcol], + pixel_index=pxcol + ) + state_manager.set_intensity_at_index(index=pxcol, intensity=validated_intensity) + +def filter_intensity_with_savitzky( + state_manager: SpectrometerStateManager +): + """ + Applies Savitzky-Golay smoothing filter to intensity data when not in peak-hold mode + and updates the intensity array in the spectrometer state variables. + + Returns status string indicating whether peak-hold mode is active. + """ + + SAVGOL_FILTER_WINDOW_SIZE = state_manager.get_savgol_filter_window_size() + + # Apply smoothing filter if not in peak-hold mode + if state_manager.should_apply_smoothing_filter(): + # Get the raw smoothed intensity array + raw_smoothed_intensity = savitzky_golay( + state_manager.get_intensity_array(), + SAVGOL_FILTER_WINDOW_SIZE, + state_manager.get_savgol_filter_poly() + ) + + # Convert to integers and update state + smoothed_intensity_array = np.array(raw_smoothed_intensity).astype(int) + state_manager.update_intensity_array(smoothed_intensity_array) diff --git a/src/capture_iteration/emittance/save_snapshot.py b/src/capture_iteration/emittance/save_snapshot.py new file mode 100644 index 0000000..f6c1acb --- /dev/null +++ b/src/capture_iteration/emittance/save_snapshot.py @@ -0,0 +1,39 @@ +from typing import Any +import time +import os + +def save_snapshot_of_current_spectrogram( + save_data: dict, + cv2: Any, + snapshot_folder: str = "storage/snapshots" + ) -> str: + """ + Save spectral image, optional waterfall image, and data CSV with timestamp into storage/. + + Parameters: + savedata (tuple): (spectrum_image, (wavelengths, intensities), [optional_waterfall_image]) + + Returns: + str: Timestamp message of last save. + """ + + # Data to store: + time_stamp = time.strftime("%Y%m%d--%H%M%S") + timenow = time.strftime("%H:%M:%S") + spectrum_vertical = save_data["spectrum_vertical"] + px_to_wavelength_array = save_data["px_to_wavelength_array"] + intensity_array = save_data["intensity_array"] + + # Store the data, folder is from main.py's path resolution, so, from the root folder of the program: + csv_file_path = os.path.join(snapshot_folder, "spectrum-" + time_stamp + ".csv") + image_file_path = os.path.join(snapshot_folder, "spectrum-" + time_stamp + ".png") + + os.makedirs(os.path.dirname(csv_file_path), exist_ok=True) + + cv2.imwrite(image_file_path, spectrum_vertical) + with open(csv_file_path, "x") as f: + f.write("Wavelength,Intensity\r\n") + for x in zip(px_to_wavelength_array, intensity_array): + f.write(str(x[0]) + "," + str(x[1]) + "\r\n") + message = "Last Save: " + timenow + return message \ No newline at end of file diff --git a/src/capture_iteration/emittance/spectroscopy_calculations.py b/src/capture_iteration/emittance/spectroscopy_calculations.py new file mode 100644 index 0000000..94c2771 --- /dev/null +++ b/src/capture_iteration/emittance/spectroscopy_calculations.py @@ -0,0 +1,209 @@ +import numpy as np +from math import factorial + + +def wavelength_to_rgb(nm: int) -> dict[str, float]: + # from: Chris Webb https://www.codedrome.com/exploring-the-visible-spectrum-in-python/ (Gomills' note: this page is down :( ) + # returns RGB vals for a given wavelength + gamma = 0.8 + max_intensity = 255 + factor = 0 + rgb = {"R": 0.0, "G": 0.0, "B": 0.0} + if 380 <= nm <= 439: + rgb["R"] = -(nm - 440) / (440 - 380) + rgb["G"] = 0.0 + rgb["B"] = 1.0 + elif 440 <= nm <= 489: + rgb["R"] = 0.0 + rgb["G"] = (nm - 440) / (490 - 440) + rgb["B"] = 1.0 + elif 490 <= nm <= 509: + rgb["R"] = 0.0 + rgb["G"] = 1.0 + rgb["B"] = -(nm - 510) / (510 - 490) + elif 510 <= nm <= 579: + rgb["R"] = (nm - 510) / (580 - 510) + rgb["G"] = 1.0 + rgb["B"] = 0.0 + elif 580 <= nm <= 644: + rgb["R"] = 1.0 + rgb["G"] = -(nm - 645) / (645 - 580) + rgb["B"] = 0.0 + elif 645 <= nm <= 780: + rgb["R"] = 1.0 + rgb["G"] = 0.0 + rgb["B"] = 0.0 + if 380 <= nm <= 419: + factor = 0.3 + 0.7 * (nm - 380) / (420 - 380) + elif 420 <= nm <= 700: + factor = 1.0 + elif 701 <= nm <= 780: + factor = 0.3 + 0.7 * (780 - nm) / (780 - 700) + if rgb["R"] > 0: + rgb["R"] = int(max_intensity * ((rgb["R"] * factor) ** gamma)) + else: + rgb["R"] = 0 + if rgb["G"] > 0: + rgb["G"] = int(max_intensity * ((rgb["G"] * factor) ** gamma)) + else: + rgb["G"] = 0 + if rgb["B"] > 0: + rgb["B"] = int(max_intensity * ((rgb["B"] * factor) ** gamma)) + else: + rgb["B"] = 0 + # display no color as gray + if (rgb["R"] + rgb["G"] + rgb["B"]) == 0: + rgb["R"] = 155 + rgb["G"] = 155 + rgb["B"] = 155 + return rgb + + +def savitzky_golay(y: np.ndarray, window_size: int, order: int, deriv: int = 0, rate: int = 1) -> np.ndarray: + # scipy + # From: https://scipy.github.io/old-wiki/pages/Cookbook/SavitzkyGolay + """ + Copyright (c) 2001-2002 Enthought, Inc. 2003-2022, SciPy Developers. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + """ + + try: + window_size = np.abs(np.int32(window_size)) + order = np.abs(np.int32(order)) + except ValueError: + raise ValueError("window_size and order have to be of type int") + if window_size % 2 != 1 or window_size < 1: + raise TypeError("window_size size must be a positive odd number") + if window_size < order + 2: + raise TypeError("window_size is too small for the polynomials order") + order_range = range(order + 1) + half_window = (window_size - 1) // 2 + # precompute coefficients + b = np.array([[k**i for i in order_range] for k in range(-half_window, half_window + 1)]) + m = np.linalg.pinv(b)[deriv] * rate**deriv * factorial(deriv) + # pad the signal at the extremes with + # values taken from the signal itself + firstvals = y[0] - np.abs(y[1 : half_window + 1][::-1] - y[0]) + lastvals = y[-1] + np.abs(y[-half_window - 1 : -1][::-1] - y[-1]) + y = np.concatenate((firstvals, y, lastvals)) + return np.convolve(m[::-1], y, mode="valid") + + +def find_peaks_in_array( + values_array: np.ndarray, + threshold: float = 0.3, + min_dist_betw_peaks: int = 1, + thres_abs: bool = False) -> np.ndarray: + # from peakutils + # from https://bitbucket.org/lucashnegri/peakutils/raw/f48d65a9b55f61fb65f368b75a2c53cbce132a0c/peakutils/peak.py + """ + The MIT License (MIT) + + Copyright (c) 2014-2022 Lucas Hermann Negri + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + """ + if isinstance(values_array, np.ndarray) and np.issubdtype(values_array.dtype, np.unsignedinteger): + raise ValueError("y must be signed") + + if not thres_abs: + threshold = threshold * (np.max(values_array) - np.min(values_array)) + np.min(values_array) + + # compute first order difference + dy = np.diff(values_array) + + # propagate left and right values successively to fill all plateau pixels (0-value) + (zeros,) = np.where(dy == 0) + + # check if the signal is totally flat + if len(zeros) == len(values_array) - 1: + return np.array([]) + + if len(zeros): + # compute first order difference of zero indexes + zeros_diff = np.diff(zeros) + # check when zeros are not chained together + (zeros_diff_not_one,) = np.add(np.where(zeros_diff != 1), 1) + # make an array of the chained zero indexes + zero_plateaus = np.split(zeros, zeros_diff_not_one) + + # fix if leftmost value in dy is zero + if zero_plateaus[0][0] == 0: + dy[zero_plateaus[0]] = dy[zero_plateaus[0][-1] + 1] + zero_plateaus.pop(0) + + # fix if rightmost value of dy is zero + if len(zero_plateaus) and zero_plateaus[-1][-1] == len(dy) - 1: + dy[zero_plateaus[-1]] = dy[zero_plateaus[-1][0] - 1] + zero_plateaus.pop(-1) + + # for each chain of zero indexes + for plateau in zero_plateaus: + median = np.median(plateau) + # set leftmost values to leftmost non zero values + dy[plateau[plateau < median]] = dy[plateau[0] - 1] + # set rightmost and middle values to rightmost non zero values + dy[plateau[plateau >= median]] = dy[plateau[-1] + 1] + + # find the peaks by using the first order difference + peaks = np.where((np.hstack([dy, 0.0]) < 0.0) & (np.hstack([0.0, dy]) > 0.0) & (np.greater(values_array, threshold)))[0] + + # handle multiple peaks, respecting the minimum distance + if peaks.size > 1 and min_dist_betw_peaks > 1: + highest = peaks[np.argsort(values_array[peaks])][::-1] + rem = np.ones(values_array.size, dtype=bool) + rem[peaks] = False + + for peak in highest: + if not rem[peak]: + sl = slice(max(0, peak - min_dist_betw_peaks), peak + min_dist_betw_peaks + 1) + rem[sl] = True + rem[peak] = False + + peaks = np.arange(values_array.size)[~rem] + + return peaks diff --git a/src/capture_iteration/transmittance/capture_transmittance_iteration.py b/src/capture_iteration/transmittance/capture_transmittance_iteration.py new file mode 100644 index 0000000..c73e8e5 --- /dev/null +++ b/src/capture_iteration/transmittance/capture_transmittance_iteration.py @@ -0,0 +1,4 @@ + + +def capture_transmittance_iteration(**kwargs): + pass \ No newline at end of file diff --git a/src/capture_iteration/waterfall/capture_waterfall.py b/src/capture_iteration/waterfall/capture_waterfall.py new file mode 100644 index 0000000..5a66211 --- /dev/null +++ b/src/capture_iteration/waterfall/capture_waterfall.py @@ -0,0 +1,3 @@ + +def capture_waterfall_iteration(**kwargs): + pass \ No newline at end of file diff --git a/src/config/README.md b/src/config/README.md new file mode 100644 index 0000000..7375aa3 --- /dev/null +++ b/src/config/README.md @@ -0,0 +1,32 @@ +# Config Parameters for PySpectrometer + +This README explains the parameters in `config.json` that you can customize for the spectrometer application. + +## Structure +The config is divided into three sections: `constants`, `runtime_config`, and `state_defaults`. + +## Constants +These are fixed settings that define the application's behavior. They cannot be changed during runtime. + +- **camera_frame_width**: Width of the camera frame in pixels (e.g., 800). Range: 100-1920. +- **camera_frame_height**: Height of the camera frame in pixels (e.g., 600). Range: 100-1080. +- **frame_duration_limit**: Tuple for frame duration limits in microseconds (e.g., [33333, 33333]). Controls camera timing. +- **testing_image_path**: Path to the image that substitutes camera's capture in testing mode (e.g., "src/config/spectrum_image_for_test.png"). Ignore this unless you're testing. + +## Runtime Config +These settings can be modified during execution. + +- **testing_mode**: Boolean (true/false). Enables testing mode, which skips camera initialization and uses a test image. +- **disp_full_screen**: Boolean (true/false). Whether to display the GUI in full screen. +- **disp_waterfall**: Boolean (true/false). Whether to display the waterfall view. KEEP false, waterfall view hasn't been developed yet + +## State Defaults +Initial values for the spectrometer's state, adjustable during runtime. + +- **default_picam_gain**: Float (e.g., 10.0). Initial camera gain. Range: 0.0-50.0. +- **default_savgol_filter_poly**: Integer (e.g., 7). Polynomial order for Savitzky-Golay filter. Range: 0-15. +- **default_savgol_filter_window_size**: Integer (e.g., 17). Window size for Savitzky-Golay filter. +- **default_minimum_distance_betw_peaks**: Integer (e.g., 50). Minimum distance between peaks in pixels. Range: 0-100. +- **default_threshold**: Integer (e.g., 20). Threshold for peak detection. Range: 0-100. + +Edit `config.json` to change these values. Ensure they match the expected types and ranges to avoid errors. diff --git a/src/config/config.json b/src/config/config.json new file mode 100644 index 0000000..4b91997 --- /dev/null +++ b/src/config/config.json @@ -0,0 +1,20 @@ +{ + "constants": { + "camera_frame_width": 800, + "camera_frame_height": 600, + "frame_duration_limit": [33333, 33333], + "testing_image_path": "src/config/spectrum_image_for_test.png" + }, + "runtime_config": { + "testing_mode": false, + "disp_full_screen": false, + "disp_waterfall": false + }, + "state_defaults": { + "default_picam_gain": 10.0, + "default_savgol_filter_poly": 7, + "default_savgol_filter_window_size": 17, + "default_minimum_distance_betw_peaks": 50, + "default_threshold": 20 + } +} diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 0000000..6e578db --- /dev/null +++ b/src/config/config.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, Field +import cv2 + + +# Application constants - user can't change them during runtime +class ApplicationConstants(BaseModel): + camera_frame_width: int = Field(default=800, ge=100, le=1920) + camera_frame_height: int = Field(default=600, ge=100, le=1080) + frame_duration_limit: tuple[int, int] = Field(default=(33333, 33333)) + spectrograph_win_name: str = "PySpectrometer 2 - Spectrograph" + waterfall_win_name: str = "PySpectrometer 2 - Waterfall" + stack_height: int = 320 + 80 + 80 + gui_font: int = cv2.FONT_HERSHEY_SIMPLEX + waterfall_array_height: int = 320 + cropped_image_height: int = 80 # We are interested in the middle 80px of the image + calibration_data_file_path: str = "storage/calibration_data/caldata.txt" + snapshot_folder: str = "storage/snapshots" + testing_image_path: str = "not_testing" + + +# Runtime configuration - can be modified during execution +class RuntimeConfiguration(BaseModel): + testing_mode: bool = Field(default=False) + spectrometer_mode: str = Field(default="emittance_spectrometer") + disp_full_screen: bool + disp_waterfall: bool = False + + +# Initial values for state manager - will be changed during runtime +class StateManagerDefaults(BaseModel): + default_picam_gain: float = Field(default=10.0, ge=0.0, le=50.0) + default_savgol_filter_poly: int = Field(default=7, ge=0, le=15) + default_savgol_filter_window_size: int = Field(default=17) + default_minimum_distance_betw_peaks: int = Field(default=50, ge=0, le=100) + default_threshold: int = Field(default=20, ge=0, le=100) + + +class UserConfiguration(BaseModel): + constants: ApplicationConstants + runtime_config: RuntimeConfiguration + state_defaults: StateManagerDefaults \ No newline at end of file diff --git a/src/config/config_loader.py b/src/config/config_loader.py new file mode 100644 index 0000000..06a385e --- /dev/null +++ b/src/config/config_loader.py @@ -0,0 +1,36 @@ +import json +import os + +from src.config.config import UserConfiguration + +def load_and_validate_config(config_path: str) -> UserConfiguration: + """ + Load configuration from a JSON file. + + Args: + config_path: Path to the configuration file. + + Returns: + SpectrometerFullConfig: The loaded configuration. + + Raises: + ValueError in case the configuration file provided by the user was not found + ValidationError in case the configuration file provided by the user is not valid + """ + if not config_path: + raise ValueError("No configuration file was provided") + + # Check if the file exists + if not os.path.exists(config_path): + raise FileNotFoundError("No configuration file found") + + # Load the configuration from the file + try: + with open(config_path, 'r') as f: + config_data = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Error decoding JSON from the configuration file: {e}") + + # Create and return the configuration object using Pydantic. + # This will raise ValidationError if the data is invalid! + return UserConfiguration.model_validate(config_data) \ No newline at end of file diff --git a/src/config/spectrum_image_for_test.png b/src/config/spectrum_image_for_test.png new file mode 100644 index 0000000..b5d1e28 Binary files /dev/null and b/src/config/spectrum_image_for_test.png differ diff --git a/src/filelist.txt b/src/filelist.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/filelist.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/gui/README.md b/src/gui/README.md new file mode 100644 index 0000000..cd0a9f3 --- /dev/null +++ b/src/gui/README.md @@ -0,0 +1,17 @@ +# GUI Module + +This module handles the graphical user interface for the spectrometer application using OpenCV. + +## Files + +- `display_image.py`: Contains functions for processing and rendering the display image, including: + - Cropping captured images to the area of interest. + - Drawing band lines, graticules, and colored spectrograms. + - Displaying wavelength information at mouse positions. + - Annotating spectral peaks with labels and flagpoles. + +- `opencv_display.py`: Manages the OpenCV display window setup and mouse event handling: + - Configures the window for full-screen or normal mode. + - Tracks mouse movements and left-click events for calibration and measurement. + +- `banner_image.png`: the banner image used in the GUI display (black brackground, contains project and authors's signatures) \ No newline at end of file diff --git a/src/gui/banner_image.png b/src/gui/banner_image.png new file mode 100644 index 0000000..689dedb Binary files /dev/null and b/src/gui/banner_image.png differ diff --git a/src/gui/display_image.py b/src/gui/display_image.py new file mode 100644 index 0000000..4d1fd29 --- /dev/null +++ b/src/gui/display_image.py @@ -0,0 +1,483 @@ +from typing import Any +import numpy as np + +from src.capture_iteration.emittance.spectroscopy_calculations import ( # type: ignore + wavelength_to_rgb, + find_peaks_in_array, +) +from src.state_manager.state_manager import SpectrometerStateManager + + +def crop_capture_to_area_of_interest( + camera_frame_height: int, camera_frame_width: int, raw_captured_image: Any, cropped_image_height: int +): + """ + This function crops the captured image to the area of interest. + + For the horizontal axis, it keeps the whole width of the image: + + x0 = 0 ---> x1 = constants.camera_frame_width + + For the vertical axis, it keeps the middle 80px of the image: + + y0 = camera_frame_height/2 - cropped_image_height/2 ----> y1 = y0 + cropped_image_height + + """ + + # We select the x limits + x_axis_start = 0 + x_axis_end = x_axis_start + camera_frame_width + + # We select the y limits + y_axis_start = int((camera_frame_height / 2) - (cropped_image_height / 2)) + y_axis_end = y_axis_start + cropped_image_height + + return raw_captured_image[y_axis_start:y_axis_end, x_axis_start:x_axis_end] + + +def draw_band_lines_on_display_image(cropped_image: Any, camera_frame_width: int, cv2: Any, halfway_height_px: int): + """ + We visually display the 3px band that will be used for wavelength intensity + calculations. We draw two lines, each 1px away from the band limits, to visually display + where we are carrying out measurements in the image + """ + + cv2.line( + cropped_image, + (0, halfway_height_px - 2), + (camera_frame_width, halfway_height_px - 2), + (255, 255, 255), + 1, + ) + cv2.line( + cropped_image, + (0, halfway_height_px + 2), + (camera_frame_width, halfway_height_px + 2), + (255, 255, 255), + 1, + ) + + +def draw_graticule_on_display_image( + cv2: Any, + static_elements: dict, + gui_font: Any, + graticule_data_dict: dict, + text_offset: int, + camera_frame_width: int, +): + """ + Draws a graticule on the display image using the provided graticule_data_dict. + This includes vertical lines at regular wavelength intervals and horizontal lines for intensity reference. + Labels are added at major wavelength steps. + """ + + # Draw vertical lines every 10nm (minor graticule) + for px_index in graticule_data_dict["ten_step_pxs"]: + cv2.line( + static_elements["graph"], + (px_index, 15), + (px_index, 320), + (200, 200, 200), + 1, + ) + + # Draw vertical lines and labels every 50nm (major graticule) + for px_index, wavelength_value_label in graticule_data_dict["fifty_step_pxs_with_label"]: + cv2.line(static_elements["graph"], (px_index, 15), (px_index, 320), (0, 0, 0), 1) + cv2.putText( + static_elements["graph"], + str(wavelength_value_label) + "nm", + (px_index - text_offset, 12), + gui_font, + 0.4, + (0, 0, 0), + 1, + cv2.LINE_AA, + ) + + # Draw horizontal lines every 64px (intensity reference) + for i in range(320): + if i >= 64 and i % 64 == 0: # skip the first line, then draw at each 64px step + cv2.line( + static_elements["graph"], + (0, i), + (camera_frame_width, i), + (100, 100, 100), + 1, + ) + + +def draw_colored_spectrogram(cv2: Any, static_elements: dict, gui_font: Any, state_manager: SpectrometerStateManager): + """ + Draw the spectral intensity graph as colored vertical lines + Creates a visual representation of the spectrum where each pixel column becomes a vertical line with: + - color based on the wavelength at that position + - height proportional to the intensity at that position + """ + + calibration_data = state_manager.get_calibration_data() + min_dist_betw_peaks = state_manager.get_minimum_distance_betw_peaks() + intensity_array = state_manager.get_intensity_array() + threshold = state_manager.get_threshold() + + px_to_wavelength_array = calibration_data["px_to_wavelength_array"] + + index = 0 + for intsty in intensity_array: + rgb = wavelength_to_rgb(int(px_to_wavelength_array[index])) # derive the color from the px_to_wavelength_array map + r = rgb["R"] + g = rgb["G"] + b = rgb["B"] + + # Draw the main line and a black border on top + cv2.line(static_elements["graph"], (index, 320), (index, 320 - intsty), (b, g, r), 1) + cv2.line( + static_elements["graph"], + (index, 319 - intsty), + (index, 320 - intsty), + (0, 0, 0), + 1, + cv2.LINE_AA, + ) + index += 1 + + # find peaks and label them + text_offset = 12 + indexes = find_peaks_in_array( + values_array=intensity_array, threshold=threshold / max(intensity_array), min_dist_betw_peaks=min_dist_betw_peaks + ) + + for i in indexes: + height = intensity_array[i] + height = 310 - height + wavelength = round(float(px_to_wavelength_array[i]), 1) + cv2.rectangle( + static_elements["graph"], + ((i - text_offset) - 2, height), + ((i - text_offset) + 60, height - 15), + (0, 255, 255), + -1, + ) + cv2.rectangle( + static_elements["graph"], + ((i - text_offset) - 2, height), + ((i - text_offset) + 60, height - 15), + (0, 0, 0), + 1, + ) + cv2.putText( + static_elements["graph"], + str(wavelength) + "nm", + (i - text_offset, height - 3), + gui_font, + 0.4, + (0, 0, 0), + 1, + cv2.LINE_AA, + ) + # flagpoles + cv2.line(static_elements["graph"], (i, height), (i, height + 10), (0, 0, 0), 1) + + +def show_wavelength_at_mouse_position( + cv2: Any, + static_elements: dict, + gui_font: Any, + state_manager: SpectrometerStateManager, +): + """ + Displays the wavelength at the mouse position when measuring mode is active. + It draws a crosshair cursor and shows the wavelength value aligned with the pixel position + for easier identification. + """ + calibration_data = state_manager.get_calibration_data() + + cursor_x, cursor_y = state_manager.get_mouse_position() + px_to_wavelength_array = calibration_data["px_to_wavelength_array"] + + # When are_we_measuring is True, it displays an interactive crosshair cursor that shows the wavelength at the mouse position. + if state_manager.is_measuring_mode_active(): + # show the cursor! + cv2.line( + static_elements["graph"], + (cursor_x, cursor_y - 140), + (cursor_x, cursor_y - 180), + (0, 0, 0), + 1, + ) + cv2.line( + static_elements["graph"], + ( + cursor_x - 20, + cursor_y - 160, + ), + ( + cursor_x + 20, + cursor_y - 160, + ), + (0, 0, 0), + 1, + ) + cv2.putText( + static_elements["graph"], + str(round(float(px_to_wavelength_array[cursor_x]), 2)) + "nm", + ( + cursor_x + 5, + cursor_y - 165, + ), + gui_font, + 0.4, + (0, 0, 0), + 1, + cv2.LINE_AA, + ) + + +def show_px_at_mouse_position_for_calibration( + cv2: Any, + static_elements: dict, + gui_font: Any, + state_manager: SpectrometerStateManager, +): + """ + This section handles calibration mode - when the user is measuring pixel positions and + recording clicks for spectrometer calibration. + + Also make sure the click array stays empty; when not in calibration mode, any previously recorded click positions become + irrelevant and should be removed to prevent accidental calibration with old data. + """ + + cursor_x, cursor_y = state_manager.get_mouse_position() + + if state_manager.is_calibration_mode_active(): + # display the points + cv2.line( + static_elements["graph"], + (cursor_x, cursor_y - 140), + (cursor_x, cursor_y - 180), + (0, 0, 0), + 1, + ) + cv2.line( + static_elements["graph"], + ( + cursor_x - 20, + cursor_y - 160, + ), + ( + cursor_x + 20, + cursor_y - 160, + ), + (0, 0, 0), + 1, + ) + cv2.putText( + static_elements["graph"], + str(cursor_x) + "px", + ( + cursor_x + 5, + cursor_y - 165, + ), + gui_font, + 0.4, + (0, 0, 0), + 1, + cv2.LINE_AA, + ) + else: + state_manager.clear_calibration_clicks() # clear the click array when not in calibration mode + + +def draw_clicks_as_circles(cv2: Any, static_elements: dict, state_manager: SpectrometerStateManager): + """ + Display calibration reference points on the spectrum graph. + For each stored click in the click array, it draws a circle and the pixel position. + Purpose: to help the user see where they clicked and what pixel position they recorded during calibration. + """ + + click_array = state_manager.get_click_array() + + if click_array: + for data in click_array: + mouse_x = data[0] + mouse_y = data[1] + cv2.circle(static_elements["graph"], (mouse_x, mouse_y), 5, (0, 0, 0), -1) + # we can display text :-) so we can work out wavelength from x-pos and display it ultimately + cv2.putText( + static_elements["graph"], + str(mouse_x), + (mouse_x + 5, mouse_y), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (0, 0, 0), + ) + + +def stack_images_and_display_spectrum( + cv2: Any, + static_elements: dict, + cropped_image: Any, + gui_font: Any, + camera_frame_width: int, + state_manager: SpectrometerStateManager, + spectrograph_win_name: str, +): + """ + Gommills: this docstring was AI generated and I didn't have time to review it yet. + --- + Assembles and displays the complete spectrometer GUI by stacking visual elements and overlaying status information. + + This function creates the main spectrometer display by vertically stacking three key components: + 1. Banner image (top section with branding/title) + 2. Cropped camera image (middle section showing the spectral band) + 3. Spectrum graph (bottom section with the colored intensity visualization) + + It then overlays real-time status information in two columns on the composite image: + - Left column: Calibration status, polynomial fit type, save status, and camera gain + - Right column: Peak hold status, Savgol filter settings, peak detection parameters + + Finally displays the complete assembled image in the specified OpenCV window. + + Args: + cv2: OpenCV module for image operations and text rendering + static_elements: Dictionary containing pre-rendered UI components: + - "banner_image": Top banner with title/branding + - "graph": Bottom spectrum visualization with colored intensity bars + cropped_image: The cropped camera frame showing the spectral band of interest + gui_font: OpenCV font object for text rendering + camera_frame_width: Width of the camera frame in pixels (used for positioning elements) + state_manager: SpectrometerStateManager instance providing access to: + - Calibration data and status messages + - Camera settings (gain) + - Processing parameters (threshold, filter settings, peak detection) + - Save operation status + - Peak hold mode status + spectrograph_win_name: Name of the OpenCV window to display the assembled image + + Returns: + numpy.ndarray: The complete assembled spectrum_vertical image array that was displayed + + Side Effects: + - Displays the assembled image in the specified OpenCV window via cv2.imshow() + - Draws white dividing lines between the stacked image sections + - Overlays cyan-colored status text on the composite image + + Note: + Text positioning is hardcoded with specific pixel coordinates: + - Left column starts at x=15, right column at x=145 + - Text lines are spaced 18 pixels apart vertically (y=15, 33, 51, 69) + - All status text uses cyan color (0, 255, 255) for consistency + """ + calibration_data = state_manager.get_calibration_data() + calibration_messages = calibration_data["calibration_messages"] + + picam_gain = state_manager.get_camera_gain() + are_we_holding_peaks_msg = state_manager.get_peak_hold_status_message() + are_we_calibrating = state_manager.is_calibration_mode_active() + threshold = state_manager.get_threshold() + savgol_filter_poly = state_manager.get_savgol_filter_poly() + minimum_distance_betw_peaks = state_manager.get_minimum_distance_betw_peaks() + + # stack the images and display the spectrum + spectrum_vertical = np.vstack((static_elements["banner_image"], cropped_image, static_elements["graph"])) + # dividing lines... + cv2.line(spectrum_vertical, (0, 80), (camera_frame_width, 80), (255, 255, 255), 1) + cv2.line(spectrum_vertical, (0, 160), (camera_frame_width, 160), (255, 255, 255), 1) + + first_column_messages = [ + f"Mode: {state_manager.get_spectrometer_mode()}", + calibration_messages["calibration_status"], + f"Camera gain: {str(picam_gain)}", + f"Are we calibrating?: {str(are_we_calibrating)}", + ] + second_column_messages = [ + are_we_holding_peaks_msg, + "Savgol Filter: " + str(savgol_filter_poly), + "Min Distance Betw Peaks: " + str(minimum_distance_betw_peaks), + "Label Threshold: " + str(threshold), + ] + + for i, message in enumerate(first_column_messages): + cv2.putText( + spectrum_vertical, + message, + (15, 15 + i * 18), + gui_font, + 0.4, + (0, 255, 255), + 1, + cv2.LINE_AA, + ) + + for i, message in enumerate(second_column_messages): + cv2.putText( + spectrum_vertical, + message, + (230, 15 + i * 18), + gui_font, + 0.4, + (0, 255, 255), + 1, + cv2.LINE_AA, + ) + + cv2.imshow(spectrograph_win_name, spectrum_vertical) + + return spectrum_vertical + + +def generate_graticule_for_gui(px_to_wavelength_array: Any) -> dict[str, list[int | tuple[int, int]]]: + """ + Generate graticule lines for the spectrometer GUI display. + + Creates vertical reference lines at regular wavelength intervals (every 10nm and 50nm) + within the measurement range limits, aligned with the actual calibration data to ensure + accurate positioning on the spectrum display. + + Args: + px_to_wavelength_array: Array mapping pixel indices to wavelength values from calibration + + Returns: + dict: Dictionary containing pixel positions for graticule lines and labels + """ + lowest_wavelength = px_to_wavelength_array[0] + highest_wavelength = px_to_wavelength_array[-1] + + # Round wavelength limits and add margin for complete coverage + lowest_wavelength_limit = int(round(lowest_wavelength)) - 10 + highest_wavelength_limit = int(round(highest_wavelength)) + 10 + + graticule_lines_dict: dict[str, list] = {} + + # Generate 10nm interval lines - find pixel positions for wavelengths divisible by 10 + ten_step_lines_px_positions = [] + for wvlng in range(lowest_wavelength_limit, highest_wavelength_limit): + if wvlng % 10 == 0: + # Found a wavelength that is a multiple of 10nm within the range. + # To align the graticule with the actual data, find the pixel index + # whose wavelength value is closest to this target using the minimum absolute difference. + px_index, wavelength_real_value = min(enumerate(px_to_wavelength_array), key=lambda x: abs(wvlng - x[1])) + + # Only include lines where the closest match is within 1nm (avoids spurious lines) + if abs(wvlng - wavelength_real_value) < 1: + ten_step_lines_px_positions.append(px_index) + + graticule_lines_dict["ten_step_pxs"] = ten_step_lines_px_positions + + # Generate 50nm interval lines with labels - these will show wavelength values + fifty_step_lines_px_positions_with_label = [] + for wvlngth in range(lowest_wavelength_limit, highest_wavelength_limit): + if wvlngth % 50 == 0: + # Find pixel index aligned with actual calibration data + px_index, wavelength_real_value = min(enumerate(px_to_wavelength_array), key=lambda x: abs(wvlngth - x[1])) + + # Only include labels where the match is within 1nm accuracy + if abs(wvlngth - wavelength_real_value) < 1: + wavelength_value_label = int(round(wavelength_real_value)) + label_data = (px_index, wavelength_value_label) + fifty_step_lines_px_positions_with_label.append(label_data) + + graticule_lines_dict["fifty_step_pxs_with_label"] = fifty_step_lines_px_positions_with_label + + return graticule_lines_dict diff --git a/src/gui/opencv_display.py b/src/gui/opencv_display.py new file mode 100644 index 0000000..f817c82 --- /dev/null +++ b/src/gui/opencv_display.py @@ -0,0 +1,69 @@ +from typing import Any + +from src.state_manager.state_manager import SpectrometerStateManager +from src.config.config import UserConfiguration + + +def set_up_open_cv_display( + state_manager: SpectrometerStateManager, + user_config: UserConfiguration, + cv2: Any, +) -> None: + DISP_FULL_SCREEN = user_config.runtime_config.disp_full_screen + SPECTROGRAPH_WIN_NAME = user_config.constants.spectrograph_win_name + CAMERA_FRAME_WIDTH = user_config.constants.camera_frame_width + STACK_HEIGHT = user_config.constants.stack_height + + if DISP_FULL_SCREEN is True: + cv2.namedWindow(SPECTROGRAPH_WIN_NAME, cv2.WND_PROP_FULLSCREEN) + cv2.setWindowProperty( + SPECTROGRAPH_WIN_NAME, + cv2.WND_PROP_FULLSCREEN, + cv2.WINDOW_FULLSCREEN, + ) + else: + cv2.namedWindow(SPECTROGRAPH_WIN_NAME, cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow( + SPECTROGRAPH_WIN_NAME, + CAMERA_FRAME_WIDTH, + STACK_HEIGHT, + ) + cv2.moveWindow(SPECTROGRAPH_WIN_NAME, 0, 0) + + # Here we track the mouse in the cv window + param_tuple = (state_manager, cv2) + cv2.setMouseCallback( + SPECTROGRAPH_WIN_NAME, + _handle_mouse_events_in_cv_window, + param_tuple, + ) + + +def _handle_mouse_events_in_cv_window(event: int, x: int, y: int, flags, param): + """ + Handle mouse events for the OpenCV window. + + Tracks the current mouse position and records left-click positions, + adjusting for a vertical offset in the coordinate system. + + Args: + event (int): The type of mouse event (e.g., movement, click). + x (int): The x-coordinate of the mouse event. + y (int): The y-coordinate of the mouse event. + flags (int): Any relevant flags passed by OpenCV. + param (any): Additional parameters (not used here). + + Behavior: + - On mouse move, updates cursorX and cursorY from mouse_events_variables + - On left button click, appends the click position (with vertical offset subtracted) + to click_array. + """ + + mouse_y_offset = 160 + state_manager, cv2 = param # param is passed from cv2.setMouseCallback (not very elegant, but no other option...) + if event == cv2.EVENT_MOUSEMOVE: + state_manager.update_mouse_position(x, y) + if event == cv2.EVENT_LBUTTONDOWN: + mouseX = x + mouseY = y - mouse_y_offset + state_manager.add_calibration_click(mouseX, mouseY) diff --git a/src/pyspectrometer_picam2.py b/src/pyspectrometer_picam2.py new file mode 100644 index 0000000..8ca1e2c --- /dev/null +++ b/src/pyspectrometer_picam2.py @@ -0,0 +1,113 @@ +from pydantic import ValidationError +from typing import Any +import logging +import cv2 + + +from src.capture_iteration.waterfall.capture_waterfall import capture_waterfall_iteration +from src.capture_iteration.transmittance.capture_transmittance_iteration import ( + capture_transmittance_iteration, +) +from src.capture_iteration.emittance.load_static import load_static_elements +from src.capture_iteration.emittance.capture_emittance_iteration import ( + capture_emittance_iteration, +) +from src.state_manager.state_manager import SpectrometerStateManager +from src.config.config_loader import load_and_validate_config +from src.gui.opencv_display import set_up_open_cv_display +from src.config.config import UserConfiguration + + +def start_spectrometer(config_path: str) -> None: + return handle_user_input(config_path) + + +# (1/4) This function handles user input and starts the spectrometer application if and only if the configuration is valid. +def handle_user_input(config_path: str) -> None: + logging.basicConfig(format="%(asctime)s - %(message)s", datefmt="%H:%M", level=logging.INFO) + logger = logging.getLogger(__name__) + + try: + user_config = load_and_validate_config(config_path) + logger.info("(1/4) Configuration loaded and validated successfully.") + except ValidationError: + return logger.error("(1/4) Configuration validation failed. Invalid configuration values.") + except FileNotFoundError: + return logger.error("(1/4) Configuration file not found. Please check the file path.") + + # Now that we have valid config, instantiate the spectrometer state manager. This contains + # the constants and mutable variables of the spectrometer and methods to interact with both. + state_manager = SpectrometerStateManager( + camera_width=user_config.constants.camera_frame_width, + default_calculus_params=user_config.state_defaults, + testing_mode=user_config.runtime_config.testing_mode, + ) + + return start_camera(user_config=user_config, state_manager=state_manager) + + +# (2/4) This function sets up the RaspBerry camera object Picamera2 +def start_camera(user_config: UserConfiguration, state_manager: SpectrometerStateManager) -> None: + spectrometer_constants = user_config.constants + + if state_manager.is_testing_mode(): + logging.info("(2/4) Camera init skipped; testing mode.") + picam2 = None + else: + from src.raspberry_camera.raspberry_camera import set_up_camera # type: ignore + + try: + picam2 = set_up_camera( + camera_frame_width=spectrometer_constants.camera_frame_width, + camera_frame_height=spectrometer_constants.camera_frame_height, + frame_duration_limit=spectrometer_constants.frame_duration_limit, + ) + except Exception as e: + return logging.error(f"(2/4) Failed to set up camera: {e}") + + logging.info("(2/4) Camera initialized successfully.") + + return set_up_gui(user_config, state_manager, picam2) + + +# (3/4) This function sets up the GUI with OpenCV +def set_up_gui(user_config: UserConfiguration, state_manager: SpectrometerStateManager, picam2: Any = None) -> None: + set_up_open_cv_display(cv2=cv2, user_config=user_config, state_manager=state_manager) + + logging.info("(3/4) OpenCV GUI set up successfully.") + + return initialize_capturing_loop(user_config, picam2, state_manager) + + +# (4/4) This function starts the capturing loop, which is the main loop of the spectrometer application. +def initialize_capturing_loop( + user_config: UserConfiguration, picam2: Any | None, state_manager: SpectrometerStateManager +) -> None: + constants = user_config.constants + static_elements = load_static_elements(cv2=cv2, camera_frame_width=constants.camera_frame_width) + + logging.info("(4/4) About to start capturing loop.") + + return _capturing_loop(picam2=picam2, state_manager=state_manager, constants=constants, static_elements=static_elements) + + +def _capturing_loop(picam2: Any, state_manager: SpectrometerStateManager, constants: Any, static_elements: dict) -> None: + spectrometer_on = True + + while spectrometer_on: + running_mode = state_manager.get_spectrometer_mode() + + if running_mode == "emittance_spectrometer": + spectrometer_on = capture_emittance_iteration( + cv2=cv2, picam2=picam2, state_manager=state_manager, static_elements=static_elements, constants=constants + ) + elif running_mode == "transmittance_spectrometer": + spectrometer_on = capture_transmittance_iteration( + cv2=cv2, picam2=picam2, state_manager=state_manager, static_elements=static_elements, constants=constants + ) + elif running_mode == "waterfall_display": + spectrometer_on = capture_waterfall_iteration( + cv2=cv2, picam2=picam2, state_manager=state_manager, static_elements=static_elements, constants=constants + ) + + return cv2.destroyAllWindows() diff --git a/src/raspberry_camera/raspberry_camera.py b/src/raspberry_camera/raspberry_camera.py new file mode 100644 index 0000000..ed12044 --- /dev/null +++ b/src/raspberry_camera/raspberry_camera.py @@ -0,0 +1,23 @@ +from picamera2 import Picamera2 + +def set_up_camera( + camera_frame_width: int, + camera_frame_height: int, + frame_duration_limit: tuple[int, int]) -> Picamera2: + """ + 2.- Raspberry camera interface set and initialized as picam2 (standard library's recommendation). + The general pattern is: + a) create camera object Picamera2 + b) create configuration and append it to this camera object + Here we create a config for video recording. + """ + + picam2 = Picamera2() + video_config = picam2.create_video_configuration( + main={"format": "RGB888", "size": (camera_frame_width, camera_frame_height)}, + controls={"FrameDurationLimits": frame_duration_limit}, + ) + picam2.configure(video_config) + picam2.start() + + return picam2 \ No newline at end of file diff --git a/src/specFunctions.py b/src/specFunctions.py deleted file mode 100644 index 0f097f7..0000000 --- a/src/specFunctions.py +++ /dev/null @@ -1,463 +0,0 @@ -''' -PySpectrometer2 Les Wright 2022 -https://www.youtube.com/leslaboratory -https://github.com/leswright1977 - -This project is a follow on from: https://github.com/leswright1977/PySpectrometer - -This is a more advanced, but more flexible version of the original program. Tk Has been dropped as the GUI to allow fullscreen mode on Raspberry Pi systems and the iterface is designed to fit 800*480 screens, which seem to be a common resolutin for RPi LCD's, paving the way for the creation of a stand alone benchtop instrument. - -Whats new: -Higher reolution (800px wide graph) -3 row pixel averaging of sensor data -Fullscreen option for the Spectrometer graph -3rd order polymonial fit of calibration data for accurate measurement. -Improved graph labelling -Labelled measurement cursors -Optional waterfall display for recording spectra changes over time. -Key Bindings for all operations - -All old features have been kept, including peak hold, peak detect, Savitsky Golay filter, and the ability to save graphs as png and data as CSV. - -For instructions please consult the readme! - -Future work: -It is planned to add in GPIO support, to allow the use of buttons and knobs to control the Spectrometer. -''' - - -import numpy as np -import time - -def wavelength_to_rgb(nm): - #from: Chris Webb https://www.codedrome.com/exploring-the-visible-spectrum-in-python/ - #returns RGB vals for a given wavelength - gamma = 0.8 - max_intensity = 255 - factor = 0 - rgb = {"R": 0, "G": 0, "B": 0} - if 380 <= nm <= 439: - rgb["R"] = -(nm - 440) / (440 - 380) - rgb["G"] = 0.0 - rgb["B"] = 1.0 - elif 440 <= nm <= 489: - rgb["R"] = 0.0 - rgb["G"] = (nm - 440) / (490 - 440) - rgb["B"] = 1.0 - elif 490 <= nm <= 509: - rgb["R"] = 0.0 - rgb["G"] = 1.0 - rgb["B"] = -(nm - 510) / (510 - 490) - elif 510 <= nm <= 579: - rgb["R"] = (nm - 510) / (580 - 510) - rgb["G"] = 1.0 - rgb["B"] = 0.0 - elif 580 <= nm <= 644: - rgb["R"] = 1.0 - rgb["G"] = -(nm - 645) / (645 - 580) - rgb["B"] = 0.0 - elif 645 <= nm <= 780: - rgb["R"] = 1.0 - rgb["G"] = 0.0 - rgb["B"] = 0.0 - if 380 <= nm <= 419: - factor = 0.3 + 0.7 * (nm - 380) / (420 - 380) - elif 420 <= nm <= 700: - factor = 1.0 - elif 701 <= nm <= 780: - factor = 0.3 + 0.7 * (780 - nm) / (780 - 700) - if rgb["R"] > 0: - rgb["R"] = int(max_intensity * ((rgb["R"] * factor) ** gamma)) - else: - rgb["R"] = 0 - if rgb["G"] > 0: - rgb["G"] = int(max_intensity * ((rgb["G"] * factor) ** gamma)) - else: - rgb["G"] = 0 - if rgb["B"] > 0: - rgb["B"] = int(max_intensity * ((rgb["B"] * factor) ** gamma)) - else: - rgb["B"] = 0 - #display no color as gray - if(rgb["R"]+rgb["G"]+rgb["B"]) == 0: - rgb["R"] = 155 - rgb["G"] = 155 - rgb["B"] = 155 - return (rgb["R"], rgb["G"], rgb["B"]) - - -def savitzky_golay(y, window_size, order, deriv=0, rate=1): - #scipy - #From: https://scipy.github.io/old-wiki/pages/Cookbook/SavitzkyGolay - ''' - Copyright (c) 2001-2002 Enthought, Inc. 2003-2022, SciPy Developers. - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - - 3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ''' - import numpy as np - from math import factorial - try: - window_size = np.abs(np.int32(window_size)) - order = np.abs(np.int32(order)) - except ValueError: - raise ValueError("window_size and order have to be of type int") - if window_size % 2 != 1 or window_size < 1: - raise TypeError("window_size size must be a positive odd number") - if window_size < order + 2: - raise TypeError("window_size is too small for the polynomials order") - order_range = range(order+1) - half_window = (window_size -1) // 2 - # precompute coefficients - b = np.mat([[k**i for i in order_range] for k in range(-half_window, half_window+1)]) - m = np.linalg.pinv(b).A[deriv] * rate**deriv * factorial(deriv) - # pad the signal at the extremes with - # values taken from the signal itself - firstvals = y[0] - np.abs( y[1:half_window+1][::-1] - y[0] ) - lastvals = y[-1] + np.abs(y[-half_window-1:-1][::-1] - y[-1]) - y = np.concatenate((firstvals, y, lastvals)) - return np.convolve( m[::-1], y, mode='valid') - -def peakIndexes(y, thres=0.3, min_dist=1, thres_abs=False): - #from peakutils - #from https://bitbucket.org/lucashnegri/peakutils/raw/f48d65a9b55f61fb65f368b75a2c53cbce132a0c/peakutils/peak.py - ''' - The MIT License (MIT) - - Copyright (c) 2014-2022 Lucas Hermann Negri - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - ''' - if isinstance(y, np.ndarray) and np.issubdtype(y.dtype, np.unsignedinteger): - raise ValueError("y must be signed") - - if not thres_abs: - thres = thres * (np.max(y) - np.min(y)) + np.min(y) - - min_dist = int(min_dist) - - # compute first order difference - dy = np.diff(y) - - # propagate left and right values successively to fill all plateau pixels (0-value) - zeros, = np.where(dy == 0) - - # check if the signal is totally flat - if len(zeros) == len(y) - 1: - return np.array([]) - - if len(zeros): - # compute first order difference of zero indexes - zeros_diff = np.diff(zeros) - # check when zeros are not chained together - zeros_diff_not_one, = np.add(np.where(zeros_diff != 1), 1) - # make an array of the chained zero indexes - zero_plateaus = np.split(zeros, zeros_diff_not_one) - - # fix if leftmost value in dy is zero - if zero_plateaus[0][0] == 0: - dy[zero_plateaus[0]] = dy[zero_plateaus[0][-1] + 1] - zero_plateaus.pop(0) - - # fix if rightmost value of dy is zero - if len(zero_plateaus) and zero_plateaus[-1][-1] == len(dy) - 1: - dy[zero_plateaus[-1]] = dy[zero_plateaus[-1][0] - 1] - zero_plateaus.pop(-1) - - # for each chain of zero indexes - for plateau in zero_plateaus: - median = np.median(plateau) - # set leftmost values to leftmost non zero values - dy[plateau[plateau < median]] = dy[plateau[0] - 1] - # set rightmost and middle values to rightmost non zero values - dy[plateau[plateau >= median]] = dy[plateau[-1] + 1] - - # find the peaks by using the first order difference - peaks = np.where( - (np.hstack([dy, 0.0]) < 0.0) - & (np.hstack([0.0, dy]) > 0.0) - & (np.greater(y, thres)) - )[0] - - # handle multiple peaks, respecting the minimum distance - if peaks.size > 1 and min_dist > 1: - highest = peaks[np.argsort(y[peaks])][::-1] - rem = np.ones(y.size, dtype=bool) - rem[peaks] = False - - for peak in highest: - if not rem[peak]: - sl = slice(max(0, peak - min_dist), peak + min_dist + 1) - rem[sl] = True - rem[peak] = False - - peaks = np.arange(y.size)[~rem] - - return peaks - - -def readcal(width): - #read in the calibration points - #compute second or third order polynimial, and generate wavelength array! - #Les Wright 28 Sept 2022 - errors = 0 - message = 0 #variable to store returned message data - try: - print("Loading calibration data...") - file = open('caldata.txt', 'r') - except: - errors = 1 - - try: - #read both the pixel numbers and wavelengths into two arrays. - lines = file.readlines() - line0 = lines[0].strip() #strip newline - pixels = line0.split(',') #split on , - pixels = [int(i) for i in pixels] #convert list of strings to ints - line1 = lines[1].strip() - wavelengths = line1.split(',') - wavelengths = [float(i) for i in wavelengths]#convert list of strings to floats - except: - errors = 1 - - try: - if (len(pixels) != len(wavelengths)): - #The Calibration points are of unequal length! - errors = 1 - if (len(pixels) < 3): - #The Cal data contains less than 3 pixels! - errors = 1 - if (len(wavelengths) < 3): - #The Cal data contains less than 3 wavelengths! - errors = 1 - except: - errors = 1 - - if errors == 1: - print("Loading of Calibration data failed (missing caldata.txt or corrupted data!") - print("Loading placeholder data...") - print("You MUST perform a Calibration to use this software!\n\n") - pixels = [0,400,800] - wavelengths = [380,560,750] - - - #create an array for the data... - wavelengthData = [] - - if (len(pixels) == 3): - print("Calculating second order polynomial...") - coefficients = np.poly1d(np.polyfit(pixels, wavelengths, 2)) - print(coefficients) - C1 = coefficients[2] - C2 = coefficients[1] - C3 = coefficients[0] - print("Generating Wavelength Data!\n\n") - for pixel in range(width): - wavelength=((C1*pixel**2)+(C2*pixel)+C3) - wavelength = round(wavelength,6) #because seriously! - wavelengthData.append(wavelength) - print("Done! Note that calibration with only 3 wavelengths will not be accurate!") - if errors == 1: - message = 0 #return message zero(errors) - else: - message = 1 #return message only 3 wavelength cal secodn order poly (Inaccurate) - - if (len(pixels) > 3): - print("Calculating third order polynomial...") - coefficients = np.poly1d(np.polyfit(pixels, wavelengths, 3)) - print(coefficients) - #note this pulls out extremely precise numbers. - #this causes slight differences in vals then when we compute manual, but hey ho, more precision - #that said, we waste that precision later, but tbh, we wouldn't get that kind of precision in - #the real world anyway! 1/10 of a nm is more than adequate! - C1 = coefficients[3] - C2 = coefficients[2] - C3 = coefficients[1] - C4 = coefficients[0] - ''' - print(C1) - print(C2) - print(C3) - print(C4) - ''' - print("Generating Wavelength Data!\n\n") - for pixel in range(width): - wavelength=((C1*pixel**3)+(C2*pixel**2)+(C3*pixel)+C4) - wavelength = round(wavelength,6) - wavelengthData.append(wavelength) - - #final job, we need to compare all the recorded wavelenths with predicted wavelengths - #and note the deviation! - #do something if it is too big! - predicted = [] - #iterate over the original pixelnumber array and predict results - for i in pixels: - px = i - y=((C1*px**3)+(C2*px**2)+(C3*px)+C4) - predicted.append(y) - - #calculate 2 squared of the result - #if this is close to 1 we are all good! - corr_matrix = np.corrcoef(wavelengths, predicted) - corr = corr_matrix[0,1] - R_sq = corr**2 - - print("R-Squared="+str(R_sq)) - - message = 2 #Multiwavelength cal, 3rd order poly - - - if message == 0: - calmsg1 = "UNCALIBRATED!" - calmsg2 = "Defaults loaded" - calmsg3 = "Perform Calibration!" - if message == 1: - calmsg1 = "Calibrated!!" - calmsg2 = "Using 3 cal points" - calmsg3 = "2nd Order Polyfit" - if message == 2: - calmsg1 = "Calibrated!!!" - calmsg2 = "Using > 3 cal points" - calmsg3 = "3rd Order Polyfit" - - returndata = [] - returndata.append(wavelengthData) - returndata.append(calmsg1) - returndata.append(calmsg2) - returndata.append(calmsg3) - return returndata - - -def writecal(clickArray): - calcomplete = False - pxdata = [] - wldata = [] - print("Enter known wavelengths for observed pixels!") - for i in clickArray: - pixel = i[0] - wavelength = input("Enter wavelength for: "+str(pixel)+"px:") - pxdata.append(pixel) - wldata.append(wavelength) - #This try except serves two purposes - #first I want to write data to the caldata.txt file without quotes - #second it validates the data in as far as no strings were entered - try: - wldata = [float(x) for x in wldata] - except: - print("Only ints or decimals are allowed!") - print("Calibration aborted!") - - pxdata = ','.join(map(str, pxdata)) #convert array to string - wldata = ','.join(map(str, wldata)) #convert array to string - f = open('caldata.txt','w') - f.write(pxdata+'\r\n') - f.write(wldata+'\r\n') - print("Calibration Data Written!") - calcomplete = True - return calcomplete - -def generateGraticule(wavelengthData): - low = wavelengthData[0] #get lowet number in list - high = wavelengthData[len(wavelengthData)-1] #get highest number - #round and int these numbers so we have our range of numbers to look at - #give a margin of 10 at each end for good measure - low = int(round(low))-10 - high = int(round(high))+10 - #print('...') - #print(low) - #print(high) - #print('...') - returndata = [] - #find positions of every whole 10nm - tens = [] - for i in range(low,high): - if (i%10==0): - #position contains pixelnumber and wavelength - position = min(enumerate(wavelengthData), key=lambda x: abs(i - x[1])) - #If the difference between the target and result is <9 show the line - #(otherwise depending on the scale we get dozens of number either end that are close to the target) - if abs(i-position[1]) <1: - #print(i) - #print(position) - tens.append(position[0]) - returndata.append(tens) - fifties = [] - for i in range(low,high): - if (i%50==0): - #position contains pixelnumber and wavelength - position = min(enumerate(wavelengthData), key=lambda x: abs(i - x[1])) - #If the difference between the target and result is <1 show the line - #(otherwise depending on the scale we get dozens of number either end that are close to the target) - if abs(i-position[1]) <1: - labelpos = position[0] - labeltxt = int(round(position[1])) - labeldata = [labelpos,labeltxt] - fifties.append(labeldata) - returndata.append(fifties) - return returndata - - -background = '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' - - - - - - - - - - - - - - - - - - - - - diff --git a/src/state_manager/AGENTS.md b/src/state_manager/AGENTS.md new file mode 100644 index 0000000..0eef818 --- /dev/null +++ b/src/state_manager/AGENTS.md @@ -0,0 +1,116 @@ + +# SpectrometerStateManager Class Description + +## Core Responsibility +Centralized management of all spectrometer state variables, providing controlled access and ensuring state consistency across the application. + +## State Variables to Store + +### 1. Spectral Data State +- `intensity_array: np.ndarray` - Current spectral intensity data (800 pixels wide) +- `waterfall_array: np.ndarray` - Waterfall display data for time-series visualization +- `px_to_wavelength_array: np.ndarray` - Pixel-to-wavelength calibration mapping +- `calibration_messages: dict` - Status messages about calibration quality and type +- `graticule_data_dict: dict` - Data for GUI graticule display + +### 2. Operating Mode State +- `are_we_holding_peaks: bool` - Peak hold mode toggle +- `are_we_measuring: bool` - Wavelength measurement mode (crosshair cursor) +- `are_we_calibrating: bool` - Calibration mode (pixel recording) +- `spectrometer_mode: str` - Current spectrometer mode (e.g., "emittance_spectrometer") +- `testing_mode: bool` - Whether in testing mode +- `spectrometer_capture_iteration: int` - Iteration counter for capture loops + +### 3. Mouse/GUI Interaction State +- `cursor_x, cursor_y: int` - Current mouse position +- `click_array: list` - Recorded calibration click positions +- `last_save_message_status: str` - Status of last data save operation + +### 4. Processing Parameters State +- `savgol_filter_poly: int` - Savitzky-Golay filter polynomial order (0-15) +- `savgol_filter_window_size: int` - Filter window size (fixed at 17) +- `minimum_distance_betw_peaks: int` - Peak detection minimum distance (0-100) +- `threshold: int` - Peak labeling threshold (0-100) + +### 5. Camera Control State +- `picam_gain: float` - Camera analog gain (0.0-50.0) + +## Methods Implemented + +### State Access Methods +```python +get_intensity_array() → np.ndarray +get_calibration_data() → dict +get_graticule_data() → dict +get_mouse_position() → tuple[int, int] +get_click_array() → list +get_spectrometer_mode() → str +is_testing_mode() → bool +is_peak_hold_active() → bool +is_measuring_mode_active() → bool +is_calibration_mode_active() → bool +get_savgol_filter_poly() → int +get_threshold() → int +get_minimum_distance_betw_peaks() → int +get_save_status_message() → str +get_camera_gain() → float +get_savgol_filter_window_size() → int +``` + +### State Modification Methods +```python +toggle_peak_hold() → bool +toggle_measuring_mode() → None # auto-disables calibration mode +toggle_calibration_mode() → None # auto-disables measuring mode +change_spectrometer_mode(new_mode: str) → None +update_intensity_array(new_data: np.ndarray) → None +set_intensity_at_index(index: int, intensity: np.unsignedinteger) → None +update_mouse_position(x: int, y: int) → None +add_calibration_click(x: int, y: int) → None +clear_calibration_clicks() → None +update_calibration_data(px_to_wavelength: np.ndarray, messages: dict, graticule_data: dict = {}) → None +set_save_status(message: str) → None +increment_capture_iteration() → None +``` + +### Parameter Adjustment Methods +```python +adjust_savgol_filter(increment: int) → int # with bounds checking 0-15 +adjust_peak_distance(increment: int) → int # with bounds checking 0-100 +adjust_threshold(increment: int) → int # with bounds checking 0-100 +adjust_camera_gain(increment: float) → float # with bounds checking 0.0-50.0 +``` + +### State Validation Methods +```python +validate_intensity_extraction(peak_hold_mode: bool, current_intensity: np.unsignedinteger, pixel_index: int) → np.unsignedinteger +should_apply_smoothing_filter() → bool +get_peak_hold_status_message() → str +spectrometer_mode_just_changed() → bool +``` + +### Initialization Methods +```python +initialize_arrays(camera_width: int) → None +set_to_defaults() → None +reset_before_mode_change() → None +``` + +## Key Design Principles + +- **Encapsulation**: All state changes go through controlled methods, no direct property access +- **Validation**: Parameter adjustments include automatic bounds checking +- **Mode Management**: Measuring and calibration modes are mutually exclusive +- **State Consistency**: Related state changes are handled atomically +- **Initialization**: Clear separation between configuration loading and state initialization + +## Integration Points + +- Replaces direct access to `spectrometer_full_config` nested properties +- Provides clean interface for keyboard event handlers +- Centralizes all state-dependent logic from GUI display functions +- Manages the complex interaction between peak hold mode and intensity processing + +## Summary + +This design eliminates the old mess of scattered variable declarations and provides a single source of truth for all spectrometer state, making the code much more maintainable and debuggable. \ No newline at end of file diff --git a/src/state_manager/state_manager.py b/src/state_manager/state_manager.py new file mode 100644 index 0000000..ccdcb9a --- /dev/null +++ b/src/state_manager/state_manager.py @@ -0,0 +1,196 @@ +from typing import Any +import numpy as np + +from src.config.config import StateManagerDefaults + + +class SpectrometerStateManager: + def __init__(self, camera_width: int, default_calculus_params: StateManagerDefaults, testing_mode: bool, running_mode: str = "emittance_spectrometer") -> None: + # Spectral Data State + self.intensity_array: np.ndarray = np.zeros(camera_width, dtype=np.uint8) + self.waterfall_array: np.ndarray = np.zeros([320, camera_width, 3], dtype=np.uint8) + self.px_to_wavelength_array: np.ndarray = np.zeros(camera_width, dtype=np.float64) + self.calibration_messages: dict[str, Any] = {} + self.graticule_data_dict: dict = {} + + # Operating Mode State + self.are_we_holding_peaks: bool = False + self.are_we_measuring: bool = False + self.are_we_calibrating: bool = False + + # Mouse/GUI Interaction State + self.cursor_x: int = 0 + self.cursor_y: int = 0 + self.click_array: list = [] + self.last_save_message_status: str = "No data saved" + + # Processing Parameters State + self.threshold: int = default_calculus_params.default_threshold + self.savgol_filter_poly: int = default_calculus_params.default_savgol_filter_poly + self.savgol_filter_window_size: int = default_calculus_params.default_savgol_filter_window_size + self.minimum_distance_betw_peaks: int = default_calculus_params.default_minimum_distance_betw_peaks + + # Camera Control State + self.picam_gain: float = 10.0 + + # Spectrometer Mode + self.spectrometer_mode: str = running_mode + self.testing_mode: bool = testing_mode + self.spectrometer_capture_iteration: int = 0 + + # State Access Methods + def get_intensity_array(self) -> np.ndarray: + return self.intensity_array + + def get_calibration_data(self) -> dict: + return {"px_to_wavelength_array": self.px_to_wavelength_array, "calibration_messages": self.calibration_messages} + + def get_graticule_data(self) -> dict: + return self.graticule_data_dict + + def get_mouse_position(self) -> tuple[int, int]: + return (self.cursor_x, self.cursor_y) + + def get_click_array(self) -> list: + return self.click_array + + def get_spectrometer_mode(self) -> str: + return self.spectrometer_mode + + def change_spectrometer_mode(self, new_mode: str) -> None: + self.spectrometer_mode = new_mode + return + + def is_testing_mode(self) -> bool: + return self.testing_mode + + def is_peak_hold_active(self) -> bool: + return self.are_we_holding_peaks + + def is_measuring_mode_active(self) -> bool: + return self.are_we_measuring + + def is_calibration_mode_active(self) -> bool: + return self.are_we_calibrating + + # State Modification Methods + def toggle_peak_hold(self) -> bool: + self.are_we_holding_peaks = not self.are_we_holding_peaks + return self.are_we_holding_peaks + + def toggle_measuring_mode(self) -> None: + self.are_we_calibrating = False + self.are_we_measuring = not self.are_we_measuring + + def toggle_calibration_mode(self) -> None: + self.are_we_measuring = False + self.are_we_calibrating = not self.are_we_calibrating + + def update_intensity_array(self, new_data: np.ndarray) -> None: + self.intensity_array = new_data + + def set_intensity_at_index(self, index: int, intensity: np.unsignedinteger) -> None: + self.intensity_array[index] = intensity + + def update_mouse_position(self, x: int, y: int) -> None: + self.cursor_x = x + self.cursor_y = y + + def add_calibration_click(self, x: int, y: int) -> None: + self.click_array.append([x, y]) + + def clear_calibration_clicks(self) -> None: + self.click_array.clear() + + def update_calibration_data(self, px_to_wavelength: np.ndarray, messages: dict, graticule_data: dict = {}) -> None: + self.px_to_wavelength_array = px_to_wavelength + self.calibration_messages = messages + if graticule_data: + self.graticule_data_dict = graticule_data + + def set_save_status(self, message: str) -> None: + self.last_save_message_status = message + + # Parameter Adjustment Methods + def adjust_savgol_filter(self, increment: int) -> int: + self.savgol_filter_poly = max(0, min(15, self.savgol_filter_poly + increment)) + return self.savgol_filter_poly + + def adjust_peak_distance(self, increment: int) -> int: + self.minimum_distance_betw_peaks = max(0, min(100, self.minimum_distance_betw_peaks + increment)) + return self.minimum_distance_betw_peaks + + def adjust_threshold(self, increment: int) -> int: + self.threshold = max(0, min(100, self.threshold + increment)) + return self.threshold + + def adjust_camera_gain(self, increment: float) -> float: + self.picam_gain = max(0.0, min(50.0, self.picam_gain + increment)) + return self.picam_gain + + def increment_capture_iteration(self) -> None: + self.spectrometer_capture_iteration += 1 + + def spectrometer_mode_just_changed(self) -> bool: + return self.spectrometer_capture_iteration == 0 + + # State Validation Methods + def validate_intensity_extraction( + self, peak_hold_mode: bool, current_intensity: np.unsignedinteger, pixel_index: int + ) -> np.unsignedinteger: + if peak_hold_mode: + if current_intensity > self.intensity_array[pixel_index]: + return current_intensity + return self.intensity_array[pixel_index] + return current_intensity + + def should_apply_smoothing_filter(self) -> bool: + return not self.are_we_holding_peaks + + def get_peak_hold_status_message(self) -> str: + return "Holdpeaks ON" if self.are_we_holding_peaks else "Holdpeaks OFF" + + def get_savgol_filter_poly(self) -> int: + return self.savgol_filter_poly + + def get_threshold(self) -> int: + return self.threshold + + def get_minimum_distance_betw_peaks(self) -> int: + return self.minimum_distance_betw_peaks + + def get_save_status_message(self) -> str: + return self.last_save_message_status + + def get_camera_gain(self) -> float: + return self.picam_gain + + def get_savgol_filter_window_size(self) -> int: + return self.savgol_filter_window_size + + # Initialization Methods + def initialize_arrays(self, camera_width: int) -> None: + self.intensity_array = np.zeros(camera_width, dtype=np.uint8) + self.waterfall_array = np.zeros([320, camera_width, 3], dtype=np.uint8) + self.px_to_wavelength_array = np.zeros(camera_width, dtype=np.float64) + + def set_to_defaults(self) -> None: + self.are_we_holding_peaks = False + self.are_we_measuring = False + self.are_we_calibrating = False + self.click_array.clear() + self.last_save_message_status = "No data saved" + self.savgol_filter_poly = 7 + self.minimum_distance_betw_peaks = 50 + self.threshold = 20 + self.picam_gain = 10.0 + + def reset_before_mode_change(self) -> None: + self.are_we_holding_peaks = False + self.are_we_measuring = False + self.are_we_calibrating = False + self.click_array.clear() + self.last_save_message_status = "No data saved" + self.intensity_array.fill(0) + self.waterfall_array.fill(0) + self.spectrometer_capture_iteration = 0 diff --git a/storage/README.md b/storage/README.md new file mode 100644 index 0000000..bb146c2 --- /dev/null +++ b/storage/README.md @@ -0,0 +1,15 @@ +# Storage Folder + +This folder contains persistent data for the spectrometer application. + +## Contents + +- **calibration_data/**: Stores calibration files, such as `caldata.txt`, used for wavelength calibration. +- **snapshots/**: Directory for saved spectrogram snapshots taken by user. + +## Developer Notes + +- Calibration data is read/written via `src/calibration/`. +- Snapshots are saved via `src/capture_iteration/emittance/save_snapshot.py`. +- Ensure paths in `config.json` match these locations. +- TODO: migrate storage structure and future improvements (e.g., migrating calibration data from simple .txt to proper CSV with descriptive headers). diff --git a/storage/calibration_data/caldata.txt b/storage/calibration_data/caldata.txt new file mode 100644 index 0000000..99a8091 --- /dev/null +++ b/storage/calibration_data/caldata.txt @@ -0,0 +1,2 @@ + + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7d899ff --- /dev/null +++ b/uv.lock @@ -0,0 +1,493 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "av" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/89/940a509ee7e9449f0c877fa984b37b7cc485546035cc67bbc353f2ac20f3/av-15.0.0.tar.gz", hash = "sha256:871c1a9becddf00b60b1294dc0bff9ff193ac31286aeec1a34039bd27e650183", size = 3833128, upload-time = "2025-07-03T16:23:48.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/81/c5d009ea9c01a513b7af6aac2ac49c0f2f7193345071cd6dd4d91bef3ab9/av-15.0.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:84e2ede9459e64e768f4bc56d9df65da9e94b704ee3eccfe2e5b1da1da754313", size = 21782026, upload-time = "2025-07-03T16:22:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/ffe9fcac35a07efc6aa0d765015efa499d88823c01499f318760460f8088/av-15.0.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9473ed92d6942c5a449a2c79d49f3425eb0272499d1a3559b32c1181ff736a08", size = 26974939, upload-time = "2025-07-03T16:22:21.493Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e7/0816e52134dc2d0259bb1aaad78573eacaf2bebc1a643de34e3384b520d6/av-15.0.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:56a53fe4e09bebd99355eaa0ce221b681eaf205bdda114f5e17fb79f3c3746ad", size = 34573486, upload-time = "2025-07-03T16:22:24.684Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f4/07cc05712e9824a4bb68beea44eb5a7369dee3f00fa258879190004b7fc5/av-15.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:247dd9a99d7ed3577b8c1e9977e811f423b04504ff36c9dcd7a4de3e6e5fe5ad", size = 38418908, upload-time = "2025-07-03T16:22:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/19/48/7f3a21a41e291f8c5b8a98f95cfef308ce1b024a634413ce910c270efd7d/av-15.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fc50a7d5f60109221ccf44f8fa4c56ce73f22948b7f19b1717fcc58f7fbc383e", size = 40010257, upload-time = "2025-07-03T16:22:31.15Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c9/ced392e82d39084544d2d0c05decb36446028928eddf0d40ec3d8fe6c050/av-15.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77deaec8943abfebd4e262924f2f452d6594cf0bc67d8d98aac0462b476e4182", size = 40381801, upload-time = "2025-07-03T16:22:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a23ad111200e27f5773e94b0b6f9e2ea492a72ded7f4787a358d9d504a8b/av-15.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:601d9b0740e47a17ec96ba2a537ebfd4d6edc859ae6f298475c06caa51f0a019", size = 37219417, upload-time = "2025-07-03T16:22:37.497Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/2ac20143b74e3792ede40bfd397ce72fa4e76a03999c2fd0aee3997b6971/av-15.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e021f67e0db7256c9f5d3d6a2a4237a4a4a804b131b33e7f2778981070519b20", size = 41242077, upload-time = "2025-07-03T16:22:40.86Z" }, + { url = "https://files.pythonhosted.org/packages/bd/30/40452705dffbfef0f5505d36218970dfeff0a86048689910219c8717b310/av-15.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:383f1b57520d790069d85fc75f43cfa32fca07f5fb3fb842be37bd596638602c", size = 31357617, upload-time = "2025-07-03T16:22:43.934Z" }, + { url = "https://files.pythonhosted.org/packages/a6/27/c2e248498ce78dd504b0b1818ce88e71e30a7e26c348bdf5d6467d7b06f7/av-15.0.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0701c116f32bd9478023f610722f6371d15ca0c068ff228d355f54a7cf23d9cb", size = 21746400, upload-time = "2025-07-03T16:22:46.604Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d8/11f8452f19f4ddc189e978b215420131db40e3919135c14a0d13520f7c94/av-15.0.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:57fb6232494ec575b8e78e5a9ef9b811d78f8d67324476ec8430ca3146751124", size = 26939576, upload-time = "2025-07-03T16:22:49.255Z" }, + { url = "https://files.pythonhosted.org/packages/00/1c/b109fd41487d91b8843f9e199b65e89ca533a612ec788b11ed0ba9812ea3/av-15.0.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:801a3e0afd5c36df70d012d083bfca67ab22d0ebd2c860c0d9432ac875bc0ad6", size = 34284344, upload-time = "2025-07-03T16:22:52.373Z" }, + { url = "https://files.pythonhosted.org/packages/99/71/aee35fa182d0a41227fbd3f4250fd94c54acdd2995025ee59dd948bba930/av-15.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d5e97791b96741b344bf6dbea4fb14481c117b1f7fe8113721e8d80e26cbb388", size = 38130346, upload-time = "2025-07-03T16:22:56.755Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c4/2d9bbc9c42a804c99bc571eeacb2fe1582fe9cfdb726616876cada937d6a/av-15.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:acb4e4aa6bb394d3a9e60feb4cb7a856fc7bac01f3c99019b1d0f11c898c682c", size = 39728857, upload-time = "2025-07-03T16:23:00.392Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d6/a5746e9fb4fdf326e9897abd7538413210e66f35ad4793fe30f87859249d/av-15.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02d2d80bdbe184f1f3f49b3f5eae7f0ff7cba0a62ab3b18be0505715e586ad29", size = 40109012, upload-time = "2025-07-03T16:23:04.1Z" }, + { url = "https://files.pythonhosted.org/packages/77/1f/da89798231ad0feacfaaea4efec4f1779060226986f97498eabe2c7c54a8/av-15.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:603f3ae751f6678df5d8b949f92c6f8257064bba8b3e8db606a24c29d31b4e25", size = 36929211, upload-time = "2025-07-03T16:23:07.694Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4c/2bcabe65a1c19e552f03540f16155a0d02cb9b7a90d31242ab3e0c7ea0d8/av-15.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:682686a9ea2745e63c8878641ec26b1787b9210533f3e945a6e07e24ab788c2e", size = 40967172, upload-time = "2025-07-03T16:23:13.488Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f0/fe14adaa670ab7a3f709805a8494fd0a2eeb6a5b18b8c59dc6014639a5b1/av-15.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:5758231163b5486dfbf664036be010b7f5ebb24564aaeb62577464be5ea996e0", size = 31332650, upload-time = "2025-07-03T16:23:16.558Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/00/a297a868e9d0784450faa7365c2172a7d6110c763e30ba861867c32ae6a9/jsonschema-4.25.0.tar.gz", hash = "sha256:e63acf5c11762c0e6672ffb61482bdf57f0876684d8d249c0fe2d730d48bc55f", size = 356830, upload-time = "2025-07-18T15:39:45.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/54/c86cd8e011fe98803d7e382fd67c0df5ceab8d2b7ad8c5a81524f791551c/jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716", size = 89184, upload-time = "2025-07-18T15:39:42.956Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" }, +] + +[[package]] +name = "libarchive-c" +version = "5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/23/e72434d5457c24113e0c22605cbf7dd806a2561294a335047f5aa8ddc1ca/libarchive_c-5.3.tar.gz", hash = "sha256:5ddb42f1a245c927e7686545da77159859d5d4c6d00163c59daff4df314dae82", size = 54349, upload-time = "2025-05-22T08:08:04.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/3f/ff00c588ebd7eae46a9d6223389f5ae28a3af4b6d975c0f2a6d86b1342b9/libarchive_c-5.3-py3-none-any.whl", hash = "sha256:651550a6ec39266b78f81414140a1e04776c935e72dfc70f1d7c8e0a3672ffba", size = 17035, upload-time = "2025-05-22T08:08:03.045Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.12.0.88" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/71/25c98e634b6bdeca4727c7f6d6927b056080668c5008ad3c8fc9e7f8f6ec/opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d", size = 95373294, upload-time = "2025-07-07T09:20:52.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/68/3da40142e7c21e9b1d4e7ddd6c58738feb013203e6e4b803d62cdd9eb96b/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5", size = 37877727, upload-time = "2025-07-07T09:13:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/33/7c/042abe49f58d6ee7e1028eefc3334d98ca69b030e3b567fe245a2b28ea6f/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81", size = 57326471, upload-time = "2025-07-07T09:13:41.26Z" }, + { url = "https://files.pythonhosted.org/packages/62/3a/440bd64736cf8116f01f3b7f9f2e111afb2e02beb2ccc08a6458114a6b5d/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92", size = 45887139, upload-time = "2025-07-07T09:13:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/68/1f/795e7f4aa2eacc59afa4fb61a2e35e510d06414dd5a802b51a012d691b37/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9", size = 67041680, upload-time = "2025-07-07T09:14:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" }, +] + +[[package]] +name = "picamera2" +version = "0.3.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "numpy" }, + { name = "pidng" }, + { name = "piexif" }, + { name = "pillow" }, + { name = "python-prctl" }, + { name = "simplejpeg" }, + { name = "tqdm" }, + { name = "videodev2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/81/713990c74e3f29a8d0f3f338b611146067745895bb252f0ec9692e10587a/picamera2-0.3.30.tar.gz", hash = "sha256:1462353d3fc02be456d9a9e3aa1070b0e2e020ce2d4a608beb56c0b5be076ce7", size = 102573, upload-time = "2025-07-24T15:52:53.796Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/14/ed80dccd4da13c3d914d56c48bf13b02cf4f92d82a4a001accfd44d0a6d3/picamera2-0.3.30-py3-none-any.whl", hash = "sha256:f220a43784a824dc9120b7f780cea7e1fc4e83bb4bcfc2e19e1b7d7819a10a8f", size = 121873, upload-time = "2025-07-24T15:52:51.901Z" }, +] + +[[package]] +name = "pidng" +version = "4.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/65/2670c465c8a63a23eb3a5e5547262e247e1aa2d3889a0a6781da9109d5f7/pidng-4.0.9.tar.gz", hash = "sha256:560eb008086f8a715fd9e1ab998817a7d4c8500a7f161b9ce6af5ab27501f82c", size = 21907, upload-time = "2022-05-06T19:09:32.093Z" } + +[[package]] +name = "piexif" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/84/a3f25cec7d0922bf60be8000c9739d28d24b6896717f44cc4cfb843b1487/piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b", size = 1011134, upload-time = "2019-07-01T15:29:23.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/d8/6f63147dd73373d051c5eb049ecd841207f898f50a5a1d4378594178f6cf/piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", size = 20691, upload-time = "2019-07-01T15:43:20.907Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pyspectrometer2-1" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python" }, + { name = "picamera2" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.2.6" }, + { name = "opencv-python", specifier = ">=4.12.0.88" }, + { name = "picamera2", specifier = ">=0.3.30" }, + { name = "pydantic", specifier = ">=2.11.7" }, +] + +[[package]] +name = "python-prctl" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/99/be5393cfe9c16376b4f515d90a68b11f1840143ac1890e9008bc176cf6a6/python-prctl-1.8.1.tar.gz", hash = "sha256:b4ca9a25a7d4f1ace4fffd1f3a2e64ef5208fe05f929f3edd5e27081ca7e67ce", size = 28033, upload-time = "2020-11-02T19:30:25.257Z" } + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/d9/991a0dee12d9fc53ed027e26a26a64b151d77252ac477e22666b9688bc16/rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f", size = 27420, upload-time = "2025-08-07T08:26:39.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/17/e67309ca1ac993fa1888a0d9b2f5ccc1f67196ace32e76c9f8e1dbbbd50c/rpds_py-0.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:19c990fdf5acecbf0623e906ae2e09ce1c58947197f9bced6bbd7482662231c4", size = 362611, upload-time = "2025-08-07T08:23:44.773Z" }, + { url = "https://files.pythonhosted.org/packages/93/2e/28c2fb84aa7aa5d75933d1862d0f7de6198ea22dfd9a0cca06e8a4e7509e/rpds_py-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6c27a7054b5224710fcfb1a626ec3ff4f28bcb89b899148c72873b18210e446b", size = 347680, upload-time = "2025-08-07T08:23:46.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/3e/9834b4c8f4f5fe936b479e623832468aa4bd6beb8d014fecaee9eac6cdb1/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09965b314091829b378b60607022048953e25f0b396c2b70e7c4c81bcecf932e", size = 384600, upload-time = "2025-08-07T08:23:48Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/744123c7b38865a965cd9e6f691fde7ef989a00a256fa8bf15b75240d12f/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f028eb47f59e9169bfdf9f7ceafd29dd64902141840633683d0bad5b04ff34", size = 400697, upload-time = "2025-08-07T08:23:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/32/97/3c3d32fe7daee0a1f1a678b6d4dfb8c4dcf88197fa2441f9da7cb54a8466/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6168af0be75bba990a39f9431cdfae5f0ad501f4af32ae62e8856307200517b8", size = 517781, upload-time = "2025-08-07T08:23:50.557Z" }, + { url = "https://files.pythonhosted.org/packages/b2/be/28f0e3e733680aa13ecec1212fc0f585928a206292f14f89c0b8a684cad1/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab47fe727c13c09d0e6f508e3a49e545008e23bf762a245b020391b621f5b726", size = 406449, upload-time = "2025-08-07T08:23:51.732Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/5d15c83e337c082d0367053baeb40bfba683f42459f6ebff63a2fd7e5518/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa01b3d5e3b7d97efab65bd3d88f164e289ec323a8c033c5c38e53ee25c007e", size = 386150, upload-time = "2025-08-07T08:23:52.822Z" }, + { url = "https://files.pythonhosted.org/packages/bf/65/944e95f95d5931112829e040912b25a77b2e7ed913ea5fe5746aa5c1ce75/rpds_py-0.27.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:6c135708e987f46053e0a1246a206f53717f9fadfba27174a9769ad4befba5c3", size = 406100, upload-time = "2025-08-07T08:23:54.339Z" }, + { url = "https://files.pythonhosted.org/packages/21/a4/1664b83fae02894533cd11dc0b9f91d673797c2185b7be0f7496107ed6c5/rpds_py-0.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc327f4497b7087d06204235199daf208fd01c82d80465dc5efa4ec9df1c5b4e", size = 421345, upload-time = "2025-08-07T08:23:55.832Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/b7303941c2b0823bfb34c71378249f8beedce57301f400acb04bb345d025/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e57906e38583a2cba67046a09c2637e23297618dc1f3caddbc493f2be97c93f", size = 561891, upload-time = "2025-08-07T08:23:56.951Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c8/48623d64d4a5a028fa99576c768a6159db49ab907230edddc0b8468b998b/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f4f69d7a4300fbf91efb1fb4916421bd57804c01ab938ab50ac9c4aa2212f03", size = 591756, upload-time = "2025-08-07T08:23:58.146Z" }, + { url = "https://files.pythonhosted.org/packages/b3/51/18f62617e8e61cc66334c9fb44b1ad7baae3438662098efbc55fb3fda453/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4c4fbbcff474e1e5f38be1bf04511c03d492d42eec0babda5d03af3b5589374", size = 557088, upload-time = "2025-08-07T08:23:59.6Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4c/e84c3a276e2496a93d245516be6b49e20499aa8ca1c94d59fada0d79addc/rpds_py-0.27.0-cp312-cp312-win32.whl", hash = "sha256:27bac29bbbf39601b2aab474daf99dbc8e7176ca3389237a23944b17f8913d97", size = 221926, upload-time = "2025-08-07T08:24:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/9d0fbcef64340db0605eb0a0044f258076f3ae0a3b108983b2c614d96212/rpds_py-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a06aa1197ec0281eb1d7daf6073e199eb832fe591ffa329b88bae28f25f5fe5", size = 233235, upload-time = "2025-08-07T08:24:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/e177aa9f39cbab060f96de4a09df77d494f0279604dc2f509263e21b05f9/rpds_py-0.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:e14aab02258cb776a108107bd15f5b5e4a1bbaa61ef33b36693dfab6f89d54f9", size = 223315, upload-time = "2025-08-07T08:24:03.337Z" }, + { url = "https://files.pythonhosted.org/packages/81/d2/dfdfd42565a923b9e5a29f93501664f5b984a802967d48d49200ad71be36/rpds_py-0.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:443d239d02d9ae55b74015234f2cd8eb09e59fbba30bf60baeb3123ad4c6d5ff", size = 362133, upload-time = "2025-08-07T08:24:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/0a2e2460c4b66021d349ce9f6331df1d6c75d7eea90df9785d333a49df04/rpds_py-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8a7acf04fda1f30f1007f3cc96d29d8cf0a53e626e4e1655fdf4eabc082d367", size = 347128, upload-time = "2025-08-07T08:24:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/35/8d/7d1e4390dfe09d4213b3175a3f5a817514355cb3524593380733204f20b9/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0f92b78cfc3b74a42239fdd8c1266f4715b573204c234d2f9fc3fc7a24f185", size = 384027, upload-time = "2025-08-07T08:24:06.841Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/78499d1a62172891c8cd45de737b2a4b84a414b6ad8315ab3ac4945a5b61/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce4ed8e0c7dbc5b19352b9c2c6131dd23b95fa8698b5cdd076307a33626b72dc", size = 399973, upload-time = "2025-08-07T08:24:08.143Z" }, + { url = "https://files.pythonhosted.org/packages/10/a1/1c67c1d8cc889107b19570bb01f75cf49852068e95e6aee80d22915406fc/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fde355b02934cc6b07200cc3b27ab0c15870a757d1a72fd401aa92e2ea3c6bfe", size = 515295, upload-time = "2025-08-07T08:24:09.711Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/700ec88e748436b6c7c4a2262d66e80f8c21ab585d5e98c45e02f13f21c0/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13bbc4846ae4c993f07c93feb21a24d8ec637573d567a924b1001e81c8ae80f9", size = 406737, upload-time = "2025-08-07T08:24:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/33/cc/6b0ee8f0ba3f2df2daac1beda17fde5cf10897a7d466f252bd184ef20162/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0744661afbc4099fef7f4e604e7f1ea1be1dd7284f357924af12a705cc7d5c", size = 385898, upload-time = "2025-08-07T08:24:12.798Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/c927b37d7d33c0a0ebf249cc268dc2fcec52864c1b6309ecb960497f2285/rpds_py-0.27.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:069e0384a54f427bd65d7fda83b68a90606a3835901aaff42185fcd94f5a9295", size = 405785, upload-time = "2025-08-07T08:24:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/8ed50746d909dcf402af3fa58b83d5a590ed43e07251d6b08fad1a535ba6/rpds_py-0.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4bc262ace5a1a7dc3e2eac2fa97b8257ae795389f688b5adf22c5db1e2431c43", size = 419760, upload-time = "2025-08-07T08:24:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/d3/60/2b2071aee781cb3bd49f94d5d35686990b925e9b9f3e3d149235a6f5d5c1/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fe6e18e5c8581f0361b35ae575043c7029d0a92cb3429e6e596c2cdde251432", size = 561201, upload-time = "2025-08-07T08:24:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/98/1f/27b67304272521aaea02be293fecedce13fa351a4e41cdb9290576fc6d81/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d93ebdb82363d2e7bec64eecdc3632b59e84bd270d74fe5be1659f7787052f9b", size = 591021, upload-time = "2025-08-07T08:24:18.999Z" }, + { url = "https://files.pythonhosted.org/packages/db/9b/a2fadf823164dd085b1f894be6443b0762a54a7af6f36e98e8fcda69ee50/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0954e3a92e1d62e83a54ea7b3fdc9efa5d61acef8488a8a3d31fdafbfb00460d", size = 556368, upload-time = "2025-08-07T08:24:20.54Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/6d135d46a129cda2e3e6d4c5e91e2cc26ea0428c6cf152763f3f10b6dd05/rpds_py-0.27.0-cp313-cp313-win32.whl", hash = "sha256:2cff9bdd6c7b906cc562a505c04a57d92e82d37200027e8d362518df427f96cd", size = 221236, upload-time = "2025-08-07T08:24:22.144Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/65d7494f5448ecc755b545d78b188440f81da98b50ea0447ab5ebfdf9bd6/rpds_py-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc79d192fb76fc0c84f2c58672c17bbbc383fd26c3cdc29daae16ce3d927e8b2", size = 232634, upload-time = "2025-08-07T08:24:23.642Z" }, + { url = "https://files.pythonhosted.org/packages/70/d9/23852410fadab2abb611733933401de42a1964ce6600a3badae35fbd573e/rpds_py-0.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b3a5c8089eed498a3af23ce87a80805ff98f6ef8f7bdb70bd1b7dae5105f6ac", size = 222783, upload-time = "2025-08-07T08:24:25.098Z" }, + { url = "https://files.pythonhosted.org/packages/15/75/03447917f78512b34463f4ef11066516067099a0c466545655503bed0c77/rpds_py-0.27.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:90fb790138c1a89a2e58c9282fe1089638401f2f3b8dddd758499041bc6e0774", size = 359154, upload-time = "2025-08-07T08:24:26.249Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fc/4dac4fa756451f2122ddaf136e2c6aeb758dc6fdbe9ccc4bc95c98451d50/rpds_py-0.27.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010c4843a3b92b54373e3d2291a7447d6c3fc29f591772cc2ea0e9f5c1da434b", size = 343909, upload-time = "2025-08-07T08:24:27.405Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/723c1ed8e6f57ed9d8c0c07578747a2d3d554aaefc1ab89f4e42cfeefa07/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9ce7a9e967afc0a2af7caa0d15a3e9c1054815f73d6a8cb9225b61921b419bd", size = 379340, upload-time = "2025-08-07T08:24:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/98/16/7e3740413de71818ce1997df82ba5f94bae9fff90c0a578c0e24658e6201/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa0bf113d15e8abdfee92aa4db86761b709a09954083afcb5bf0f952d6065fdb", size = 391655, upload-time = "2025-08-07T08:24:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/e0/63/2a9f510e124d80660f60ecce07953f3f2d5f0b96192c1365443859b9c87f/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb91d252b35004a84670dfeafadb042528b19842a0080d8b53e5ec1128e8f433", size = 513017, upload-time = "2025-08-07T08:24:31.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4e/cf6ff311d09776c53ea1b4f2e6700b9d43bb4e99551006817ade4bbd6f78/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db8a6313dbac934193fc17fe7610f70cd8181c542a91382531bef5ed785e5615", size = 402058, upload-time = "2025-08-07T08:24:32.613Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/5e36096d474cb10f2a2d68b22af60a3bc4164fd8db15078769a568d9d3ac/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce96ab0bdfcef1b8c371ada2100767ace6804ea35aacce0aef3aeb4f3f499ca8", size = 383474, upload-time = "2025-08-07T08:24:33.767Z" }, + { url = "https://files.pythonhosted.org/packages/db/a2/3dff02805b06058760b5eaa6d8cb8db3eb3e46c9e452453ad5fc5b5ad9fe/rpds_py-0.27.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:7451ede3560086abe1aa27dcdcf55cd15c96b56f543fb12e5826eee6f721f858", size = 400067, upload-time = "2025-08-07T08:24:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/67/87/eed7369b0b265518e21ea836456a4ed4a6744c8c12422ce05bce760bb3cf/rpds_py-0.27.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:32196b5a99821476537b3f7732432d64d93a58d680a52c5e12a190ee0135d8b5", size = 412085, upload-time = "2025-08-07T08:24:36.267Z" }, + { url = "https://files.pythonhosted.org/packages/8b/48/f50b2ab2fbb422fbb389fe296e70b7a6b5ea31b263ada5c61377e710a924/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a029be818059870664157194e46ce0e995082ac49926f1423c1f058534d2aaa9", size = 555928, upload-time = "2025-08-07T08:24:37.573Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/b18eb51045d06887666c3560cd4bbb6819127b43d758f5adb82b5f56f7d1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3841f66c1ffdc6cebce8aed64e36db71466f1dc23c0d9a5592e2a782a3042c79", size = 585527, upload-time = "2025-08-07T08:24:39.391Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/a3dd6470fc76499959b00ae56295b76b4bdf7c6ffc60d62006b1217567e1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:42894616da0fc0dcb2ec08a77896c3f56e9cb2f4b66acd76fc8992c3557ceb1c", size = 554211, upload-time = "2025-08-07T08:24:40.6Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d1/ee5fd1be395a07423ac4ca0bcc05280bf95db2b155d03adefeb47d5ebf7e/rpds_py-0.27.0-cp313-cp313t-win32.whl", hash = "sha256:b1fef1f13c842a39a03409e30ca0bf87b39a1e2a305a9924deadb75a43105d23", size = 216624, upload-time = "2025-08-07T08:24:42.204Z" }, + { url = "https://files.pythonhosted.org/packages/1c/94/4814c4c858833bf46706f87349c37ca45e154da7dbbec9ff09f1abeb08cc/rpds_py-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:183f5e221ba3e283cd36fdfbe311d95cd87699a083330b4f792543987167eff1", size = 230007, upload-time = "2025-08-07T08:24:43.329Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a5/8fffe1c7dc7c055aa02df310f9fb71cfc693a4d5ccc5de2d3456ea5fb022/rpds_py-0.27.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f3cd110e02c5bf17d8fb562f6c9df5c20e73029d587cf8602a2da6c5ef1e32cb", size = 362595, upload-time = "2025-08-07T08:24:44.478Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c7/4e4253fd2d4bb0edbc0b0b10d9f280612ca4f0f990e3c04c599000fe7d71/rpds_py-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d0e09cf4863c74106b5265c2c310f36146e2b445ff7b3018a56799f28f39f6f", size = 347252, upload-time = "2025-08-07T08:24:45.678Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/3d1a954d30f0174dd6baf18b57c215da03cf7846a9d6e0143304e784cddc/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f689ab822f9b5eb6dfc69893b4b9366db1d2420f7db1f6a2adf2a9ca15ad64", size = 384886, upload-time = "2025-08-07T08:24:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/e0/52/3c5835f2df389832b28f9276dd5395b5a965cea34226e7c88c8fbec2093c/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e36c80c49853b3ffda7aa1831bf175c13356b210c73128c861f3aa93c3cc4015", size = 399716, upload-time = "2025-08-07T08:24:48.174Z" }, + { url = "https://files.pythonhosted.org/packages/40/73/176e46992461a1749686a2a441e24df51ff86b99c2d34bf39f2a5273b987/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6de6a7f622860af0146cb9ee148682ff4d0cea0b8fd3ad51ce4d40efb2f061d0", size = 517030, upload-time = "2025-08-07T08:24:49.52Z" }, + { url = "https://files.pythonhosted.org/packages/79/2a/7266c75840e8c6e70effeb0d38922a45720904f2cd695e68a0150e5407e2/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4045e2fc4b37ec4b48e8907a5819bdd3380708c139d7cc358f03a3653abedb89", size = 408448, upload-time = "2025-08-07T08:24:50.727Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/a7efc572b8e235093dc6cf39f4dbc8a7f08e65fdbcec7ff4daeb3585eef1/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da162b718b12c4219eeeeb68a5b7552fbc7aadedf2efee440f88b9c0e54b45d", size = 387320, upload-time = "2025-08-07T08:24:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/a2/eb/9ff6bc92efe57cf5a2cb74dee20453ba444b6fdc85275d8c99e0d27239d1/rpds_py-0.27.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:0665be515767dc727ffa5f74bd2ef60b0ff85dad6bb8f50d91eaa6b5fb226f51", size = 407414, upload-time = "2025-08-07T08:24:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bd/3b9b19b00d5c6e1bd0f418c229ab0f8d3b110ddf7ec5d9d689ef783d0268/rpds_py-0.27.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203f581accef67300a942e49a37d74c12ceeef4514874c7cede21b012613ca2c", size = 420766, upload-time = "2025-08-07T08:24:55.917Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/521a7b1079ce16258c70805166e3ac6ec4ee2139d023fe07954dc9b2d568/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7873b65686a6471c0037139aa000d23fe94628e0daaa27b6e40607c90e3f5ec4", size = 562409, upload-time = "2025-08-07T08:24:57.17Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/65db5bfb14ccc55e39de8419a659d05a2a9cd232f0a699a516bb0991da7b/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:249ab91ceaa6b41abc5f19513cb95b45c6f956f6b89f1fe3d99c81255a849f9e", size = 590793, upload-time = "2025-08-07T08:24:58.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/b8/82d368b378325191ba7aae8f40f009b78057b598d4394d1f2cdabaf67b3f/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2f184336bc1d6abfaaa1262ed42739c3789b1e3a65a29916a615307d22ffd2e", size = 558178, upload-time = "2025-08-07T08:24:59.756Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/f270bddbfbc3812500f8131b1ebbd97afd014cd554b604a3f73f03133a36/rpds_py-0.27.0-cp314-cp314-win32.whl", hash = "sha256:d3c622c39f04d5751408f5b801ecb527e6e0a471b367f420a877f7a660d583f6", size = 222355, upload-time = "2025-08-07T08:25:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/fdab055b1460c02ed356a0e0b0a78c1dd32dc64e82a544f7b31c9ac643dc/rpds_py-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf824aceaeffff029ccfba0da637d432ca71ab21f13e7f6f5179cd88ebc77a8a", size = 234007, upload-time = "2025-08-07T08:25:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a8/694c060005421797a3be4943dab8347c76c2b429a9bef68fb2c87c9e70c7/rpds_py-0.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:86aca1616922b40d8ac1b3073a1ead4255a2f13405e5700c01f7c8d29a03972d", size = 223527, upload-time = "2025-08-07T08:25:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f9/77f4c90f79d2c5ca8ce6ec6a76cb4734ee247de6b3a4f337e289e1f00372/rpds_py-0.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:341d8acb6724c0c17bdf714319c393bb27f6d23d39bc74f94221b3e59fc31828", size = 359469, upload-time = "2025-08-07T08:25:04.648Z" }, + { url = "https://files.pythonhosted.org/packages/c0/22/b97878d2f1284286fef4172069e84b0b42b546ea7d053e5fb7adb9ac6494/rpds_py-0.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b96b0b784fe5fd03beffff2b1533dc0d85e92bab8d1b2c24ef3a5dc8fac5669", size = 343960, upload-time = "2025-08-07T08:25:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b0/dfd55b5bb480eda0578ae94ef256d3061d20b19a0f5e18c482f03e65464f/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c431bfb91478d7cbe368d0a699978050d3b112d7f1d440a41e90faa325557fd", size = 380201, upload-time = "2025-08-07T08:25:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/28/22/e1fa64e50d58ad2b2053077e3ec81a979147c43428de9e6de68ddf6aff4e/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e222a44ae9f507d0f2678ee3dd0c45ec1e930f6875d99b8459631c24058aec", size = 392111, upload-time = "2025-08-07T08:25:09.149Z" }, + { url = "https://files.pythonhosted.org/packages/49/f9/43ab7a43e97aedf6cea6af70fdcbe18abbbc41d4ae6cdec1bfc23bbad403/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:184f0d7b342967f6cda94a07d0e1fae177d11d0b8f17d73e06e36ac02889f303", size = 515863, upload-time = "2025-08-07T08:25:10.431Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9bd59dcc636cd04d86a2d20ad967770bf348f5eb5922a8f29b547c074243/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a00c91104c173c9043bc46f7b30ee5e6d2f6b1149f11f545580f5d6fdff42c0b", size = 402398, upload-time = "2025-08-07T08:25:11.819Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/f099328c6c85667aba6b66fa5c35a8882db06dcd462ea214be72813a0dd2/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a37dd208f0d658e0487522078b1ed68cd6bce20ef4b5a915d2809b9094b410", size = 384665, upload-time = "2025-08-07T08:25:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c5/9c1f03121ece6634818490bd3c8be2c82a70928a19de03467fb25a3ae2a8/rpds_py-0.27.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92f3b3ec3e6008a1fe00b7c0946a170f161ac00645cde35e3c9a68c2475e8156", size = 400405, upload-time = "2025-08-07T08:25:14.417Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b8/e25d54af3e63ac94f0c16d8fe143779fe71ff209445a0c00d0f6984b6b2c/rpds_py-0.27.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b3db5fae5cbce2131b7420a3f83553d4d89514c03d67804ced36161fe8b6b2", size = 413179, upload-time = "2025-08-07T08:25:15.664Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d1/406b3316433fe49c3021546293a04bc33f1478e3ec7950215a7fce1a1208/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5355527adaa713ab693cbce7c1e0ec71682f599f61b128cf19d07e5c13c9b1f1", size = 556895, upload-time = "2025-08-07T08:25:17.061Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bc/3697c0c21fcb9a54d46ae3b735eb2365eea0c2be076b8f770f98e07998de/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fcc01c57ce6e70b728af02b2401c5bc853a9e14eb07deda30624374f0aebfe42", size = 585464, upload-time = "2025-08-07T08:25:18.406Z" }, + { url = "https://files.pythonhosted.org/packages/63/09/ee1bb5536f99f42c839b177d552f6114aa3142d82f49cef49261ed28dbe0/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3001013dae10f806380ba739d40dee11db1ecb91684febb8406a87c2ded23dae", size = 555090, upload-time = "2025-08-07T08:25:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2c/363eada9e89f7059199d3724135a86c47082cbf72790d6ba2f336d146ddb/rpds_py-0.27.0-cp314-cp314t-win32.whl", hash = "sha256:0f401c369186a5743694dd9fc08cba66cf70908757552e1f714bfc5219c655b5", size = 218001, upload-time = "2025-08-07T08:25:21.761Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3f/d6c216ed5199c9ef79e2a33955601f454ed1e7420a93b89670133bca5ace/rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391", size = 230993, upload-time = "2025-08-07T08:25:23.34Z" }, +] + +[[package]] +name = "simplejpeg" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/f0/c63f8be025a809ccb7383dff65f5b195ba14d5eb30a52cfaa3fd18f88536/simplejpeg-1.8.2.tar.gz", hash = "sha256:b06e253a896c7fc4f257e11baf96d783817cea41360d0962a70c2743ba57bc30", size = 5485180, upload-time = "2025-02-25T16:17:05.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4c/3bd332dd3af05a18d48fe114e5c32876c85fadea8e521b06e5f93306ac8f/simplejpeg-1.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6185d3dbaac759f94663ad1b119f115e54fd472082d5ab0247292c06dfc4e8e0", size = 492706, upload-time = "2025-02-25T16:16:35.121Z" }, + { url = "https://files.pythonhosted.org/packages/18/46/d215d05d4302cde2f37c4d131a5cce96430d81543c024c0ce39e4b29df45/simplejpeg-1.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9feef167f7b6d77a1e00a3f08026876d9776c8b94b7f68b1ebe66c11270a486", size = 443258, upload-time = "2025-02-25T16:16:37.619Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/e00b23fd3db37d90f8c0ada5484e800d56d76123e647cdb365e75f4aa905/simplejpeg-1.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70ea3ba9930ea991fa2a464efdcdd9c9db7e7f717949f4d291a87bdfbfc966a5", size = 445395, upload-time = "2025-02-25T16:16:41.42Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7f/f75153b2323d9bbb5eb4a4ff68f0c74bc5bfe5cf259d6b56a168847e24ce/simplejpeg-1.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e0217d767e0ecf7add67a41b9a368741dcc8e36ff89c1fc0a445cabc23f41b0", size = 425841, upload-time = "2025-02-25T16:16:43.057Z" }, + { url = "https://files.pythonhosted.org/packages/62/65/d78d840b9d9e923f85df27897308bd570ab51eae4221125bd0a337b3e73a/simplejpeg-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:098cf1eb0ab5c26c94793e190bb8abd4439536b271b865ad37f594b1c9bb8d8f", size = 306992, upload-time = "2025-02-25T16:16:44.579Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d7/8319bdfacddc18fbceeb315ad1e941ef0faf9d50fc223d3cf6098b044e42/simplejpeg-1.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:af6e5bf19570c8dcd7a1216c53e799155bade38f7f338a64db74be159115e18f", size = 491854, upload-time = "2025-02-25T16:16:46.818Z" }, + { url = "https://files.pythonhosted.org/packages/1b/64/1b5f74b0479a1cee06a19bf2d14f3b18b81adc921004f65881751af42921/simplejpeg-1.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dfb97636e30344fe4ec9383d6af5efd177bd68a35a8696cc0358f5a7aa82342", size = 441919, upload-time = "2025-02-25T16:16:49.529Z" }, + { url = "https://files.pythonhosted.org/packages/84/c1/ebe6ed532518bb7c103f6a40623fd531ba2b2ab233adc29050ab2cc14cdd/simplejpeg-1.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55da32610384f8f2b9156298648de265c3c07c4d14b7e718471b28cc447e6186", size = 443503, upload-time = "2025-02-25T16:16:50.921Z" }, + { url = "https://files.pythonhosted.org/packages/18/74/3208dda42376239b86ee5e7eb77fb22f2af4d29b8c1376d2d9b8b755e39c/simplejpeg-1.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b36245beb2aa10e3279301236e82bb54886360b1da75a3c6eadd7065a8fab89d", size = 424082, upload-time = "2025-02-25T16:16:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d8/835b8f4ffd4bb176830e98f8a5fd2751322e48364f7c7a48d66c9201e05d/simplejpeg-1.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:dee5bef85e766e69952de157c5833b47ed8345333f14365a65216179770ad608", size = 306707, upload-time = "2025-02-25T16:16:54.589Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "videodev2" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/82/ffdba8838b1f24b83268863a8f66fe9334d7f28a5b9c368f9c48f7516e69/videodev2-0.0.4.tar.gz", hash = "sha256:c34ba70491d148c23a08cbacd8efabeb413cff5baa943a7548ac4abd1eb19e2a", size = 50108, upload-time = "2025-07-23T10:18:51.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/30/4982441a03860ab8f656702d8a2c13d0cf6f56d65bfb78fe288028dcb473/videodev2-0.0.4-py3-none-any.whl", hash = "sha256:d35f7ab39ddb06d50fec96a99bfc8d5b8b525bc7ea03788259d386393f1a64ba", size = 49923, upload-time = "2025-07-23T10:18:50.378Z" }, +]