Skip to content

[Feat]: Image loading using ocaml-imagelib library#113

Merged
pawaskar-shreya merged 22 commits into
claudiusFX:mainfrom
pawaskar-shreya:feat/picture-loading
Aug 28, 2025
Merged

[Feat]: Image loading using ocaml-imagelib library#113
pawaskar-shreya merged 22 commits into
claudiusFX:mainfrom
pawaskar-shreya:feat/picture-loading

Conversation

@pawaskar-shreya
Copy link
Copy Markdown
Collaborator

Helloww @mdales 👋👋

Here's PR for the image loading module

  • In this PR, I have added the picture module which loads PNG image using ocaml-imagelib.
  • I have also added picture type t, but it is not directly made available for public use, abstractions have been put up in place to make sure that the original image data isn't tampered
  • Initially I was taking width and height both as parameters from the user, but then after some testing I saw the aspect ratio get distorted. So now we only take scale from the user and the corresponding dimensions are then caldulated.
  • Also, both the original and scaled dimensions are made available
  • I have taken care of a couple of things in this PR, scaling for eg is kept to be float instead of int, to make sure the scaling is smooth and not like a step function
  • The offsets when positive shift the img to the right, but negative offsets have been taken care to shift to the left
  • If the image happens to be greater than the sdl window, it is not stretched or fitted into the sdl window. but is sort of rendered as much as possible with changing the dimensions of the screen
  • A draw_picture function has been introduced in the framebuffer.ml
  • Picture is also now added as a primitive

I have also written a test_picture module but havent added it here, as it is using an image, so I would like to have your opinion here on how to proceed.

@pawaskar-shreya
Copy link
Copy Markdown
Collaborator Author

Ohh this is a strange error for the build checks

Run opam exec -- dune build
File "src/dune", line 10, characters 40-53:
10 |  (libraries tsdl giflib crunch imagelib imagelib.unix))
                                             ^^^^^^^^^^^^^
Error: Library "imagelib.unix" not found.
-> required by library "claudius" in _build/default/src
-> required by _build/default/META.claudius
-> required by alias all
-> required by alias default
(cd _build/default/src && /home/runner/work/Claudius/Claudius/_opam/bin/ocaml-crunch -m plain -o builtins.ml ../fonts)
Generating builtins.ml
Skipping generation of .mli
Error: Process completed with exit code 1.

this is working locally for me but maybe that can also be attributed to the fact that I had vendored imagelib. Is there any way that you think we can make this work on CI?

@mdales
Copy link
Copy Markdown
Collaborator

mdales commented Aug 22, 2025

this is working locally for me but maybe that can also be attributed to the fact that I had vendored imagelib. Is there any way that you think we can make this work on CI?

Yes - how do you think CI knows about the other things Claudius depends on @pawaskar-shreya? If you can find that then you can solve this :)

Copy link
Copy Markdown
Collaborator

@mdales mdales left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, loving this PR: good decision to bring in Imagelib to do the image decoding here rather than just relying on GIFLib, you've added images to the right places (as a primitive and as a draw function on frame buffer).

I have two things that I think you should explore here:

Firstly the scaling confused me in the API - I think the scaling is a good idea, and encourage you not to lose it, but should it be on Picture.t or should it be an argument to the drawing function? When I read the original API, my assumption was that having provided the scaling then when I call pixels I'd get the image already scaled. But that isn't the case, the scaling is only used by the drawing routine - on which case, perhaps we should just pass the scaling there as an argument? That would then make it easier to implement something like this: https://www.youtube.com/watch?v=_P00jYB81ZQ without having to create many copies of the Picture.

Secondly, and more importantly, this abstraction doesn't work if I load two or more images, because the way the palette system works. All the images will have a palette that starts at 0 and works up, and whilst I can change the palette, if I try to draw two images the one that is used second will have the winning palette, making the first image look odd.

I think given this limitation of Claudius is might be best to take image loading away from the user, and have Claudius do it, in a sort of similar way to how I built up a palette programmatically in my FunOCaml talk (https://github.com/mdales/funocaml24/blob/main/bin/main.ml#L146). It could perhaps work like this:

  • Screen.create would take an optional list of image filenames, along with the user provided palette
  • Inside Screen.create we load the images into a list of Picture.t, and each image in turn will get an offset to add to its palette definition
  • The palette for Claudius is then a concatenation of the user provided palette (which might be empty if they just want to use the image's palette) concatenated with all the image palettes in turn.
  • Screen can then have a pictures property of type Picture.t array that returns the images in the same order that the file names were provided.

This means the user never has to do any palette manipulation by default (which I think new users won't want to worry about), and you can ensure that all loaded images have their own unique set of colours.

We already do this for fonts if you don't want to use the built in system font: you provide it to screen, and then when you want it later you call Screen.font. This also promotes lack of global variables which is a good thing in programming, and particularly in functional programming as global variables are effectively side effects of sorts.

How does this sound?

I know that's a lot of feedback, but overall I'm super happy with this technically, I just think the APIs need considering a little more.

Comment thread src/picture.mli Outdated
type t
(** Abstract type representing a loaded picture *)

val load : string -> float -> t
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of thoughts here:

Could we call this from_file just to keep it consistent with giflib?

I think the common case will be that most people leave the scale as 1.0, and so I'd advocate that the scale parameter is an optional parameter, and by default we don't do any scaling.

I'd also suggest the filename argument should be last so as to make it easier to do things like:

let img = get_user_input_of_filename () |> Picture.load

Comment thread src/picture.mli Outdated
val scaled_width : t -> int
(** [scaled_width pic] returns the width in pixels after applying the scale. *)

val scaled_height : t -> int
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For both this and the original sizes can we just have a single method that returns a tuple containing width and height? That then is consistent with Screen.dimensions.

Comment thread src/picture.mli Outdated
(** [set_scale pic s] returns a new picture with the scale factor updated to
[s]. *)

val scaled_width : t -> int
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should not need the scaled_ prefix - you set the scaling at load time, and then that's it, that's your image.

Comment thread src/picture.mli
val original_width : t -> int
(** [original_width pic] returns the original width in pixels. *)

val original_height : t -> int
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why you added this, but when designing an API one should keep it as small as possible, as then there is less to test, less to maintain, etc.

What use is the original size to the user? surely they only need to know the size it will be drawn at?

Comment thread src/framebuffer.mli Outdated
(** [filled_polygon points colour framebuffer] Draws a filled polygon made from
the list of [points] in the specified [colour] into [framebuffer]. *)

val draw_picture : Picture.t -> int -> int -> t -> unit
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

draw_char and draw_string take the position as the first arguments - for consistency this should match those (I don't think they're better than what you've done - just we should be consistent).

Comment thread src/picture.ml Outdated
Comment thread src/picture.ml
in

let palette_rgb_24 =
0x000000
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment about why you're doing this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid this by using a sentinel value (say -1) in the pixel array rather than 0?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think this isn't needed: If we have an invalid value rather than zero to represent transparent in the pixel array.

Comment thread src/framebuffer.ml
let idx = (src_y * src_w) + src_x in
let color_index = pixels.(idx) in

if color_index <> 0 then
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a comment here as to why 0 is being special cased here

Comment thread src/framebuffer.ml Outdated
@mdales
Copy link
Copy Markdown
Collaborator

mdales commented Aug 22, 2025

I had a brief panic that we would not be able to use Imagelib, as the opam info says it is licensed under GPL-3.0, and that is not compatible with the ICS/MIT style license Claudius uses.

However, when I go to the project page, it is show as using the LGPL, which is okay:

https://github.com/rlepigre/ocaml-imagelib/blob/master/LICENSE

@pawaskar-shreya
Copy link
Copy Markdown
Collaborator Author

Heyy @mdales

the builds are again failing, this time I have added imagelib and iamgelib.unix to both dune-project and opam. it just says dune not found ☹️

I don't understand why this is the case. do I need to raise a seperate PR for these: imagelib and iamgelib.unix ; as even if these changes are added in this PR only, CI still follows the old CI workflow. But does that also seems contradicting as the dependencies are anyways fetched from opam file.

Run opam exec -- dune build
  opam exec -- dune build
  shell: /usr/bin/bash -e {0}
  env:
    OPAMCOLOR: always
    OPAMCONFIRMLEVEL: unsafe-yes
    OPAMDOWNLOADJOBS: 4
    OPAMERRLOGLEN: 0
    OPAMEXTERNALSOLVER: builtin-0install
    OPAMPRECISETRACKING: 1
    OPAMRETRIES: 10
    OPAMROOT: /home/runner/.opam
    OPAMSOLVERTIMEOUT: 600
    OPAMYES: 1
    CLICOLOR_FORCE: 1
[ERROR] Command not found 'dune'

does explicitly adding a dune install stanza in main.yml sound like a good option here?

@mdales
Copy link
Copy Markdown
Collaborator

mdales commented Aug 25, 2025

@pawaskar-shreya I don't understand why main builds and your PR does not, you've not done anything I can see to cause dune to fail. That said, we don't have dune as a dependancy. Can you add a stage to the CI script main.yml like so and see if this fixes it?

...
      - name: Install system requirements
        run: sudo apt install -y libsdl2-dev

      - name: Install dune
        run: opam install dune

      - name: Install ocaml requirements
        run: opam install . --deps-only --with-test --with-dev-setup --with-doc
...

@pawaskar-shreya
Copy link
Copy Markdown
Collaborator Author

That said, we don't have dune as a dependancy

dune is present as a dependency in both dune-project and claudius.opam. So my thought was that it was fetched initially from there only in all our previous PRs. Dont know why it is not doing the same now. ☹️

Can you add a stage to the CI script main.yml like so and see if this fixes it?

Yupp! raising a PR!

Copy link
Copy Markdown
Collaborator

@mdales mdales left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working with my requests! I think that change makes things more easier to use for new users and the common use case.

The one thing I think that isn't done in an idiomatic functional way is that you've added some global state for building up the palettes. You can do this without introducing global state I think if in the Screen.create function you replace the list.map with a list.fold of some sorts and as you load each image you work out its offset and then pass that as an optional argument to the next image load.

Otherwise I think the other small things are:

  • You still have save separate width/height getters for the image rather than just a single call to get both
  • No tests
  • Still using a 0 for the sentinel transparent value, where as I think we should avoid using valid colours

@pawaskar-shreya
Copy link
Copy Markdown
Collaborator Author

The one thing I think that isn't done in an idiomatic functional way is that you've added some global state for building up the palettes. You can do this without introducing global state I think if in the Screen.create function you replace the list.map with a list.fold of some sorts and as you load each image you work out its offset and then pass that as an optional argument to the next image load.

Okayy yea, I should try this!

Otherwise I think the other small things are:

You still have save separate width/height getters for the image rather than just a single call to get both
No tests
Still using a 0 for the sentinel transparent value, where as I think we should avoid using valid colours

Yupp! I am yet to do a bit work here as right now the pixel bleeding problem is also sort of in my way. But yea, once that is done, I shall get these changes in too!

@pawaskar-shreya
Copy link
Copy Markdown
Collaborator Author

Heyy @mdales

I have made some of the changes:

  • I eliminated the use of global state
  • And I have added tests for picture module
  • I also did the change you suggested for test dune for having the assets available in builds. We've got it working! Thankssss! 🥳
  • There are some changes yet to be made, but lets get them in once we are done with our talk!

But please do suggest me and give your feedback on the recent commits!

Copy link
Copy Markdown
Collaborator

@mdales mdales left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely done with the no globals!

I've marked this as approved, as we can merge this, however it would be nice if time allows to review the use of the 0 element in each image palette - but I can always fix that afterwards, because this already makes Claudius better, so I'm happy for it to merge if you need to focus on your presentation.

Comment thread src/palette.mli
(** [updated_entry pal index new_color] checks for the index then returns a new
palette with the entry at [index] updated to [new_color]. *)

val concat : t list -> t
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we'd have something in test_palette.ml corresponding to this.

Comment thread src/picture.ml
in

let palette_rgb_24 =
0x000000
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think this isn't needed: If we have an invalid value rather than zero to represent transparent in the pixel array.

Comment thread src/picture.ml

type t = { palette : Palette.t; pixels : int array; width : int; height : int }

let load_png_as_indexed (filepath : string) : Palette.t * int array * int * int
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whilst you're only testing it for PNG, doesn't using ImageLib here mean you can load any image that ImageLib supports? Nothing here seems PNG specific. Can this just become load_as_indexed?

Comment thread src/framebuffer.ml
pixel_write fb_x fb_y color_index fb
done
done;
fb.dirty <- true
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't needed now as you call pixel_write, which itself will set the dirty flag. Not bad, just less code is always good :)

Comment thread src/primitives.mli
| FilledTriangle of point * point * point * int
| Char of point * Font.t * char * int
| String of point * Font.t * string * int
| Picture of point * Picture.t
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ideally would have a scale on it - I realised I couldn't use the primitives for the flying ocaml logo example because there is no scale argument here.

@pawaskar-shreya pawaskar-shreya merged commit 77a6f75 into claudiusFX:main Aug 28, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants